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:
+99
-636
@@ -4,45 +4,68 @@
|
||||
# Purpose
|
||||
# RepoFoundry creates a Gitea repository, optionally an empty GitHub
|
||||
# repository with a Gitea -> GitHub push mirror, and a local project with
|
||||
# the SQA-QC-Framework. This version (MIL-001) validates the configuration
|
||||
# and credentials, checks the required tools and asks for the project
|
||||
# details. It does NOT contact GitHub or Gitea and changes nothing on disk;
|
||||
# it only prints a summary of what it collected.
|
||||
# the SQA-QC-Framework. This version (MIL-002) validates the configuration
|
||||
# and credentials, asks for the project details, checks both hosts with
|
||||
# read-only requests (tokens, owners, names, licence, SSH) and, with
|
||||
# --apply, creates the repositories and the mirror. Choosing GitHub also
|
||||
# applies the AGPL-3.0 license to the Gitea repository. The local project
|
||||
# is not created yet.
|
||||
#
|
||||
# Dry run by default
|
||||
# Without --apply the script only reads from GitHub and Gitea (GET
|
||||
# requests) and prints what it would create. With --apply it prints the plan
|
||||
# and asks for a final yes before it creates anything. Nothing is ever
|
||||
# deleted: if a step fails, the script reports what exists and how to
|
||||
# continue, and a repeated run offers to reuse the empty repositories.
|
||||
#
|
||||
# Usage
|
||||
# create-project.sh [--config FILE] [--env FILE]
|
||||
# create-project.sh [--apply] [--config FILE] [--env FILE]
|
||||
# create-project.sh --help | --version
|
||||
#
|
||||
# Options
|
||||
# --apply create the repositories and the mirror (after a final yes)
|
||||
# --config FILE service addresses (default: config.env in the project root)
|
||||
# --env FILE credentials (default: .env in the project root)
|
||||
# -h, --help show this help
|
||||
# --version show the version
|
||||
#
|
||||
# Files (parsed, never sourced)
|
||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL
|
||||
# config.env GITHUB_API_URL, GITHUB_WEB_URL, GITEA_URL, GITEA_API_URL and
|
||||
# the optional GITEA_SSH_PORT (default 10022) and
|
||||
# MIRROR_INTERVAL (default 10m0s)
|
||||
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
|
||||
#
|
||||
# Environment
|
||||
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
|
||||
# TMPDIR where the private temporary directory is created
|
||||
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
|
||||
# REPOFOUNDRY_SYNC_WAIT seconds to wait before reading the first mirror
|
||||
# sync result (default: 3)
|
||||
# TMPDIR where the private temporary directory is created
|
||||
#
|
||||
# Requires
|
||||
# bash 4.4 or later, git, curl, mktemp; jq is optional (used when present).
|
||||
# Also the base tools sed, grep, head, tr, rm, rmdir and uname, and stat
|
||||
# (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
||||
# bash 4.4 or later, git, curl, mktemp; jq and ssh are optional (jq is used
|
||||
# for JSON when present; ssh is used for the Gitea SSH test).
|
||||
# Also the base tools sed, grep, head, tr, sleep, rm, rmdir and uname, and
|
||||
# stat (GNU "stat -c" or BSD "stat -f"; only used outside Windows).
|
||||
#
|
||||
# Implements
|
||||
# MIL-001 tasks 1 to 6 (issues #3 to #8), user story US-001.01 and UC-001
|
||||
# steps 1 to 3; see docs/. Deviation from the request: its second
|
||||
# GITEA_URL key is named GITEA_API_URL.
|
||||
# MIL-001 tasks 1 to 6 and MIL-002 tasks 1 to 5 (issues #3 to #13), user
|
||||
# stories US-001.01 and US-001.02, UC-001 steps 1 to 7; see docs/. Deviation
|
||||
# from the request: its second GITEA_URL key is named GITEA_API_URL.
|
||||
#
|
||||
# Tracing
|
||||
# set -x is switched off while the script runs, because a trace would print
|
||||
# every secret the script handles.
|
||||
#
|
||||
# Structure
|
||||
# This file is the entry point. The work is split by responsibility into
|
||||
# the files in lib/ next to it (one job per file, see the first lines of
|
||||
# each file): constants, output, temp, util, validate, config, tools, json,
|
||||
# http, api, prompts, project, hosts, preflight, steps, plan, repositories,
|
||||
# mirror, apply and cli. The files are loaded from this directory only.
|
||||
#
|
||||
# Exit codes
|
||||
# 0 success, 1 a failed check or bad input, 2 a usage error.
|
||||
# 0 success (or a dry run, or a "no" at the final question), 1 a failed
|
||||
# check, bad input or a failed step, 2 a usage error.
|
||||
set -Eeuo pipefail
|
||||
|
||||
if [[ $- == *x* ]]; then
|
||||
@@ -55,17 +78,9 @@ if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4)));
|
||||
exit 1
|
||||
fi
|
||||
|
||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||
readonly VERSION="0.1.0"
|
||||
readonly EXIT_FAILURE=1
|
||||
readonly EXIT_USAGE=2
|
||||
readonly MAX_VALUE_LENGTH=2048
|
||||
readonly MAX_DESCRIPTION_LENGTH=350
|
||||
readonly HTTP_TIMEOUT_SECONDS=30
|
||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL)
|
||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||
|
||||
# Where this script lives; the library files and the project root are found
|
||||
# from here, never from the current directory.
|
||||
readonly SCRIPT_FILE="${BASH_SOURCE[0]}"
|
||||
case "${BASH_SOURCE[0]}" in
|
||||
*/*) script_path_dir="${BASH_SOURCE[0]%/*}" ;;
|
||||
*) script_path_dir="." ;;
|
||||
@@ -78,622 +93,61 @@ unset script_path_dir
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
readonly PROJECT_ROOT
|
||||
|
||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
TMP_DIR=""
|
||||
HAS_JQ=0
|
||||
HTTP_STATUS=0
|
||||
HTTP_BODY_FILE=""
|
||||
HTTP_ERROR=""
|
||||
REPLY=""
|
||||
SECRET_VALUES=()
|
||||
TEMP_FILES=()
|
||||
declare -A CONFIG=()
|
||||
declare -A CREDENTIALS=()
|
||||
declare -A PROJECT=()
|
||||
# shellcheck source=lib/constants.sh
|
||||
source "$SCRIPT_DIR/lib/constants.sh"
|
||||
# shellcheck source=lib/output.sh
|
||||
source "$SCRIPT_DIR/lib/output.sh"
|
||||
# shellcheck source=lib/temp.sh
|
||||
source "$SCRIPT_DIR/lib/temp.sh"
|
||||
# shellcheck source=lib/util.sh
|
||||
source "$SCRIPT_DIR/lib/util.sh"
|
||||
# shellcheck source=lib/validate.sh
|
||||
source "$SCRIPT_DIR/lib/validate.sh"
|
||||
# shellcheck source=lib/config.sh
|
||||
source "$SCRIPT_DIR/lib/config.sh"
|
||||
# shellcheck source=lib/tools.sh
|
||||
source "$SCRIPT_DIR/lib/tools.sh"
|
||||
# shellcheck source=lib/json.sh
|
||||
source "$SCRIPT_DIR/lib/json.sh"
|
||||
# shellcheck source=lib/http.sh
|
||||
source "$SCRIPT_DIR/lib/http.sh"
|
||||
# shellcheck source=lib/api.sh
|
||||
source "$SCRIPT_DIR/lib/api.sh"
|
||||
# shellcheck source=lib/prompts.sh
|
||||
source "$SCRIPT_DIR/lib/prompts.sh"
|
||||
# shellcheck source=lib/project.sh
|
||||
source "$SCRIPT_DIR/lib/project.sh"
|
||||
# shellcheck source=lib/hosts.sh
|
||||
source "$SCRIPT_DIR/lib/hosts.sh"
|
||||
# shellcheck source=lib/preflight.sh
|
||||
source "$SCRIPT_DIR/lib/preflight.sh"
|
||||
# shellcheck source=lib/steps.sh
|
||||
source "$SCRIPT_DIR/lib/steps.sh"
|
||||
# shellcheck source=lib/plan.sh
|
||||
source "$SCRIPT_DIR/lib/plan.sh"
|
||||
# shellcheck source=lib/repositories.sh
|
||||
source "$SCRIPT_DIR/lib/repositories.sh"
|
||||
# shellcheck source=lib/mirror.sh
|
||||
source "$SCRIPT_DIR/lib/mirror.sh"
|
||||
# shellcheck source=lib/apply.sh
|
||||
source "$SCRIPT_DIR/lib/apply.sh"
|
||||
# shellcheck source=lib/cli.sh
|
||||
source "$SCRIPT_DIR/lib/cli.sh"
|
||||
|
||||
# ---------------------------------------------------------------- output
|
||||
|
||||
# redact TEXT: print TEXT with every known secret value replaced.
|
||||
redact() {
|
||||
local text="$1" secret
|
||||
for secret in "${SECRET_VALUES[@]}"; do
|
||||
if [[ -n $secret ]]; then
|
||||
text="${text//"$secret"/[redacted]}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$(redact "$*")"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$(redact "$*")" >&2
|
||||
}
|
||||
|
||||
# die [--code N] MESSAGE: print "error: MESSAGE" and exit (default code 1).
|
||||
die() {
|
||||
local code=$EXIT_FAILURE
|
||||
if [[ ${1:-} == --code ]]; then
|
||||
code="$2"
|
||||
shift 2
|
||||
# finish runs on every exit: it reports what a run that started creating
|
||||
# things did or did not do, then removes the temporary files. It keeps the
|
||||
# exit status of the run.
|
||||
finish() {
|
||||
local code=$?
|
||||
if ((IS_CREATION_STARTED)); then
|
||||
report_outcome "$code"
|
||||
fi
|
||||
printf 'error: %s\n' "$(redact "$*")" >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ${0##*/} [--config FILE] [--env FILE]
|
||||
${0##*/} --help | --version
|
||||
EOF
|
||||
}
|
||||
|
||||
usage_error() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit "$EXIT_USAGE"
|
||||
}
|
||||
|
||||
# on_error LINE: report an unexpected failure without echoing the command,
|
||||
# because a command line could contain a value that must stay private.
|
||||
on_error() {
|
||||
printf 'error: unexpected failure near line %s of %s\n' "$1" "${0##*/}" >&2
|
||||
}
|
||||
|
||||
# ------------------------------------------------------- temporary files
|
||||
|
||||
# Files are removed one by one and the directory with rmdir: a recursive
|
||||
# delete is never needed and never used.
|
||||
cleanup() {
|
||||
local file
|
||||
for file in "${TEMP_FILES[@]}"; do
|
||||
rm -f -- "$file"
|
||||
done
|
||||
if [[ -n $TMP_DIR && -d $TMP_DIR ]]; then
|
||||
# rmdir fails only if something unexpected is left inside; leave it
|
||||
# rather than delete files this script did not create.
|
||||
rmdir -- "$TMP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
setup_temp_dir() {
|
||||
TMP_DIR="$(umask 077 && mktemp -d "${TMPDIR:-/tmp}/repofoundry.XXXXXX")"
|
||||
}
|
||||
|
||||
# make_temp_file: create a private file in TMP_DIR and return it in REPLY.
|
||||
make_temp_file() {
|
||||
REPLY="$(umask 077 && mktemp "$TMP_DIR/file.XXXXXX")"
|
||||
TEMP_FILES+=("$REPLY")
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------ small helpers
|
||||
|
||||
trim() {
|
||||
local text="$1"
|
||||
text="${text#"${text%%[![:space:]]*}"}"
|
||||
text="${text%"${text##*[![:space:]]}"}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# in_list NEEDLE ITEM...: succeed if NEEDLE equals one of the items.
|
||||
in_list() {
|
||||
local needle="$1" item
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ $item == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_control_character() {
|
||||
[[ $1 == *[[:cntrl:]]* ]]
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- validators
|
||||
|
||||
is_valid_repo_name() {
|
||||
local name="$1"
|
||||
[[ $name =~ ^[A-Za-z0-9._-]{1,100}$ ]] || return 1
|
||||
[[ $name != . && $name != .. && $name != *.git ]]
|
||||
}
|
||||
|
||||
is_valid_gitea_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]]
|
||||
}
|
||||
|
||||
is_valid_github_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ ]]
|
||||
}
|
||||
|
||||
is_valid_description() {
|
||||
((${#1} <= MAX_DESCRIPTION_LENGTH)) && ! has_control_character "$1"
|
||||
}
|
||||
|
||||
is_valid_directory() {
|
||||
local path="$1"
|
||||
[[ -n $path && ${#path} -le 4096 && $path != -* ]] &&
|
||||
! has_control_character "$path"
|
||||
}
|
||||
|
||||
# https URL without user info, query or fragment, so it can never carry a
|
||||
# credential.
|
||||
is_valid_base_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Like is_valid_base_url but a query string is allowed (for API requests).
|
||||
is_valid_request_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?(\?[A-Za-z0-9._~%+=&,-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Access tokens: no quotes, backslashes or whitespace, so a token cannot
|
||||
# break out of the curl configuration it is written to.
|
||||
is_valid_token() {
|
||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
||||
}
|
||||
|
||||
normalize_url() {
|
||||
local url="$1"
|
||||
while [[ $url == */ ]]; do
|
||||
url="${url%/}"
|
||||
done
|
||||
printf '%s' "$url"
|
||||
}
|
||||
|
||||
# --------------------------------------------------- config file parsing
|
||||
|
||||
# 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"
|
||||
}
|
||||
|
||||
# ------------------------------------------------- configuration checks
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- tool check
|
||||
|
||||
check_tools() {
|
||||
local tool
|
||||
local missing=()
|
||||
for tool in git curl mktemp; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing+=("$tool")
|
||||
fi
|
||||
done
|
||||
if ((${#missing[@]} > 0)); then
|
||||
die "required tool(s) not found: ${missing[*]}. Install them and try again."
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HAS_JQ=1
|
||||
else
|
||||
HAS_JQ=0
|
||||
warn "jq not found; using the built-in JSON reader (install jq for stricter parsing)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- JSON
|
||||
|
||||
# json_escape TEXT: escape TEXT for use inside a JSON string.
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/\\n}"
|
||||
text="${text//$'\r'/\\r}"
|
||||
text="${text//$'\t'/\\t}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# json_get FILE KEY: print the string, number or boolean value of KEY.
|
||||
# With jq only the top-level key is read. Without jq the first occurrence of
|
||||
# the key anywhere in the file is used, which is enough for the flat fields
|
||||
# the GitHub and Gitea APIs return (name, id, html_url, ...).
|
||||
json_get() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
if ((HAS_JQ)); then
|
||||
# jq on Windows ends lines with CRLF; strip the CR so values stay clean.
|
||||
jq -r --arg key "$key" \
|
||||
'if has($key) and .[$key] != null then .[$key] | tostring else empty end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\(\"[^\"]*\"\|[0-9][0-9]*\|true\|false\)" "$file" || true; } |
|
||||
head -n 1 |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- HTTP
|
||||
|
||||
describe_http_status() {
|
||||
case "$1" in
|
||||
401) printf 'authentication failed: the token is missing, expired or invalid' ;;
|
||||
403) printf 'the token is valid but not allowed to do this (check its scopes)' ;;
|
||||
404) printf 'not found (check the name, the owner and the token access)' ;;
|
||||
409 | 422) printf 'rejected (the name may already exist or be invalid)' ;;
|
||||
429) printf 'rate limited; wait and try again' ;;
|
||||
5??) printf 'the server reported an error; try again later' ;;
|
||||
*) printf 'unexpected HTTP status %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
describe_curl_error() {
|
||||
case "$1" in
|
||||
6) printf 'could not resolve the host name' ;;
|
||||
7) printf 'could not connect' ;;
|
||||
28) printf 'the request timed out' ;;
|
||||
35 | 51 | 58 | 60) printf 'the TLS connection failed' ;;
|
||||
*) printf 'curl failed with exit code %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# http_request METHOD URL SCHEME TOKEN [BODY]
|
||||
# SCHEME is "token" (Gitea) or "bearer" (GitHub). The token goes into a
|
||||
# private curl config file, never onto the command line where other users
|
||||
# could see it. Redirects are not followed, so the token is only ever sent
|
||||
# to the host named in URL. On success HTTP_STATUS and HTTP_BODY_FILE are
|
||||
# set; on a network failure the function returns 1 with HTTP_ERROR set.
|
||||
# shellcheck disable=SC2034 # HTTP_* are results read by the callers
|
||||
http_request() {
|
||||
local method="$1" url="$2" scheme="$3" token="$4" body="${5:-}"
|
||||
local header config_file body_file out_file host curl_status=0
|
||||
local data_args=()
|
||||
[[ $method =~ ^(GET|POST|PUT|PATCH|DELETE)$ ]] ||
|
||||
die "internal error: unsupported HTTP method"
|
||||
is_valid_request_url "$url" ||
|
||||
die "refusing to call an invalid or non-https URL"
|
||||
is_valid_token "$token" || die "refusing to send a malformed token"
|
||||
case "$scheme" in
|
||||
token) header="Authorization: token $token" ;;
|
||||
bearer) header="Authorization: Bearer $token" ;;
|
||||
*) die "internal error: unknown authentication scheme" ;;
|
||||
esac
|
||||
make_temp_file
|
||||
config_file="$REPLY"
|
||||
make_temp_file
|
||||
out_file="$REPLY"
|
||||
{
|
||||
printf 'url = "%s"\n' "$url"
|
||||
printf 'request = "%s"\n' "$method"
|
||||
printf 'header = "%s"\n' "$header"
|
||||
printf 'header = "Accept: application/json"\n'
|
||||
printf 'header = "User-Agent: %s/%s"\n' "$PROJECT_NAME" "$VERSION"
|
||||
} >"$config_file"
|
||||
if [[ -n $body ]]; then
|
||||
make_temp_file
|
||||
body_file="$REPLY"
|
||||
printf '%s' "$body" >"$body_file"
|
||||
printf 'header = "Content-Type: application/json"\n' >>"$config_file"
|
||||
data_args=(--data-binary "@$body_file")
|
||||
fi
|
||||
# curl's own error text is dropped: the exit code is mapped to a message
|
||||
# that never contains the request.
|
||||
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
||||
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
||||
--config "$config_file" "${data_args[@]}" 2>/dev/null)" || curl_status=$?
|
||||
if ((curl_status != 0)); then
|
||||
host="${url#https://}"
|
||||
host="${host%%/*}"
|
||||
HTTP_STATUS=0
|
||||
HTTP_ERROR="could not reach $host: $(describe_curl_error "$curl_status")"
|
||||
return 1
|
||||
fi
|
||||
HTTP_BODY_FILE="$out_file"
|
||||
HTTP_ERROR=""
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- prompts
|
||||
|
||||
# prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the
|
||||
# answer; the accepted answer is returned in REPLY.
|
||||
prompt_value() {
|
||||
local label="$1" default="$2" validator="$3" hint="$4" answer
|
||||
while true; do
|
||||
if [[ -n $default ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" >&2
|
||||
else
|
||||
printf '%s: ' "$label" >&2
|
||||
fi
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
if "$validator" "$answer"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: $hint"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_choice LABEL DEFAULT CHOICE...: the answer is returned in REPLY.
|
||||
prompt_choice() {
|
||||
local label="$1" default="$2" answer
|
||||
shift 2
|
||||
while true; do
|
||||
printf '%s (%s) [%s]: ' "$label" "$(IFS=/ && echo "$*")" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
answer="${answer,,}"
|
||||
if in_list "$answer" "$@"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: choose one of $*"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_yes_no LABEL DEFAULT: DEFAULT is y or n; REPLY is 1 (yes) or 0 (no).
|
||||
prompt_yes_no() {
|
||||
local label="$1" default="$2" answer
|
||||
while true; do
|
||||
printf '%s (y/n) [%s]: ' "$label" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
case "${answer,,}" in
|
||||
y | yes)
|
||||
REPLY=1
|
||||
return 0
|
||||
;;
|
||||
n | no)
|
||||
REPLY=0
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
warn "invalid $label: answer y or n"
|
||||
done
|
||||
}
|
||||
|
||||
collect_project_details() {
|
||||
prompt_value "Repository name" "" is_valid_repo_name \
|
||||
"use letters, digits, '.', '_' or '-' (at most 100), not ending in .git"
|
||||
PROJECT[name]="$REPLY"
|
||||
prompt_value "Description (optional)" "" is_valid_description \
|
||||
"at most $MAX_DESCRIPTION_LENGTH characters and no control characters"
|
||||
PROJECT[description]="$REPLY"
|
||||
prompt_choice "Visibility" private private public
|
||||
PROJECT[visibility]="$REPLY"
|
||||
prompt_value "Gitea owner (user or organization)" "" is_valid_gitea_owner \
|
||||
"use letters, digits, '.', '_' or '-' (at most 39)"
|
||||
PROJECT[gitea_owner]="$REPLY"
|
||||
prompt_yes_no "Also create a GitHub repository (applies the AGPL license)" y
|
||||
PROJECT[has_github]="$REPLY"
|
||||
PROJECT[github_owner]=""
|
||||
if ((PROJECT[has_github])); then
|
||||
prompt_value "GitHub owner (user or organization)" \
|
||||
"${CREDENTIALS[GITHUB_USER]:-}" is_valid_github_owner \
|
||||
"use letters, digits or '-' (at most 39)"
|
||||
PROJECT[github_owner]="$REPLY"
|
||||
fi
|
||||
prompt_value "Local directory" "./${PROJECT[name]}" is_valid_directory \
|
||||
"must not be empty, start with '-' or contain control characters"
|
||||
PROJECT[directory]="$REPLY"
|
||||
prompt_yes_no "Enable the plan gate" n
|
||||
PROJECT[is_plan_gate_enabled]="$REPLY"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------- summary
|
||||
|
||||
yes_no() {
|
||||
if (($1)); then
|
||||
printf 'yes'
|
||||
else
|
||||
printf 'no'
|
||||
fi
|
||||
}
|
||||
|
||||
credential_state() {
|
||||
if [[ -n ${CREDENTIALS[$1]:-} ]]; then
|
||||
printf 'set'
|
||||
else
|
||||
printf 'not set'
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
say ""
|
||||
say "$PROJECT_NAME $VERSION: nothing has been created yet."
|
||||
say "Collected details:"
|
||||
say " Repository : ${PROJECT[name]} (${PROJECT[visibility]})"
|
||||
say " Description : ${PROJECT[description]:-(none)}"
|
||||
say " Gitea : ${CONFIG[GITEA_URL]}/${PROJECT[gitea_owner]}/${PROJECT[name]}"
|
||||
if ((PROJECT[has_github])); then
|
||||
say " GitHub : ${CONFIG[GITHUB_WEB_URL]}/${PROJECT[github_owner]}/${PROJECT[name]} (AGPL license applied)"
|
||||
else
|
||||
say " GitHub : not used"
|
||||
fi
|
||||
say " Directory : ${PROJECT[directory]}"
|
||||
say " Plan gate : $(yes_no "${PROJECT[is_plan_gate_enabled]}")"
|
||||
say "Credentials : GITEA_TOKEN $(credential_state GITEA_TOKEN)," \
|
||||
"GITHUB_PAT $(credential_state GITHUB_PAT)"
|
||||
say "Creating the repositories and the project comes in later phases."
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- main
|
||||
|
||||
parse_args() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--config)
|
||||
(($# >= 2)) || usage_error "--config needs a file"
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--env)
|
||||
(($# >= 2)) || usage_error "--env needs a file"
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
sed -n '2,/^set -Eeuo/p' "${BASH_SOURCE[0]}" | sed -e '$d' -e 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
exit 0
|
||||
;;
|
||||
*) usage_error "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
cleanup
|
||||
}
|
||||
|
||||
main() {
|
||||
trap 'on_error "$LINENO"' ERR
|
||||
trap cleanup EXIT
|
||||
trap finish EXIT
|
||||
is_valid_repo_name "$PROJECT_NAME" ||
|
||||
die "REPOFOUNDRY_NAME is not a valid project name"
|
||||
parse_args "$@"
|
||||
@@ -704,7 +158,16 @@ main() {
|
||||
if ((PROJECT[has_github])); then
|
||||
require_github_credentials
|
||||
fi
|
||||
init_steps
|
||||
print_summary
|
||||
run_preflight
|
||||
print_plan
|
||||
if ((IS_APPLY)); then
|
||||
apply_plan
|
||||
else
|
||||
say ""
|
||||
say "Dry run: nothing was created. Run again with --apply to create it."
|
||||
fi
|
||||
}
|
||||
|
||||
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# api.sh - Calls to the GitHub and Gitea APIs and the reporting of a refused call.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: host_label, api_call, server_message, fail_request, expect_status
|
||||
|
||||
host_label() {
|
||||
case "$1" in
|
||||
gitea) printf 'Gitea' ;;
|
||||
github) printf 'GitHub' ;;
|
||||
*) die "internal error: unknown host" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# api_call HOST METHOD PATH [BODY]: HOST is gitea or github. A network
|
||||
# failure ends the run; the HTTP status is left in HTTP_STATUS.
|
||||
api_call() {
|
||||
local host="$1" method="$2" path="$3" body="${4:-}"
|
||||
case "$host" in
|
||||
gitea)
|
||||
http_request "$method" "${CONFIG[GITEA_API_URL]}$path" token \
|
||||
"${CREDENTIALS[GITEA_TOKEN]}" "$body" || die "$HTTP_ERROR"
|
||||
;;
|
||||
github)
|
||||
http_request "$method" "${CONFIG[GITHUB_API_URL]}$path" bearer \
|
||||
"${CREDENTIALS[GITHUB_PAT]}" "$body" || die "$HTTP_ERROR"
|
||||
;;
|
||||
*) die "internal error: unknown host" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# server_message: the "message" of the last response, cleaned and shortened.
|
||||
server_message() {
|
||||
local message
|
||||
# A response that is not JSON must not stop the error report.
|
||||
message="$(json_get "$HTTP_BODY_FILE" message 2>/dev/null || true)"
|
||||
message="${message//[[:cntrl:]]/ }"
|
||||
printf '%s' "${message:0:160}"
|
||||
}
|
||||
|
||||
fail_request() {
|
||||
local detail message
|
||||
detail="$(describe_http_status "$HTTP_STATUS")"
|
||||
message="$(server_message)"
|
||||
die "$1: $detail${message:+ (the server says: $message)}"
|
||||
}
|
||||
|
||||
# expect_status CONTEXT CODE...: go on if the last status is one of CODE,
|
||||
# otherwise stop with CONTEXT and the server's own words.
|
||||
expect_status() {
|
||||
local context="$1" code
|
||||
shift
|
||||
for code in "$@"; do
|
||||
if [[ $HTTP_STATUS == "$code" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
fail_request "$context"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# apply.sh - The only code that changes anything: confirmations and the apply flow.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: create_all, confirm_reuse, apply_plan
|
||||
|
||||
create_all() {
|
||||
IS_CREATION_STARTED=1
|
||||
if ((PROJECT[has_github])); then
|
||||
create_repository github
|
||||
fi
|
||||
create_repository gitea
|
||||
if ((PROJECT[has_github])); then
|
||||
configure_mirror
|
||||
fi
|
||||
}
|
||||
|
||||
confirm_reuse() {
|
||||
local host
|
||||
for host in github gitea; do
|
||||
if ! is_reused "$host"; then
|
||||
continue
|
||||
fi
|
||||
prompt_yes_no "The $(host_label "$host") repository $(repo_url "$host") already exists and has no real content. Reuse it" n
|
||||
if ! ((REPLY)); then
|
||||
die "stopped: choose another name or remove the existing repository"
|
||||
fi
|
||||
done
|
||||
if ((${STATE[reuse_gitea]:-0})) && ((PROJECT[has_github])) &&
|
||||
[[ ${STATE[gitea_repo]} == empty ]]; then
|
||||
warn "the empty Gitea repository is reused as it is: the $AGPL_LICENSE_KEY license is not added to it"
|
||||
fi
|
||||
}
|
||||
|
||||
# apply_plan: the only place that changes anything on GitHub or Gitea.
|
||||
apply_plan() {
|
||||
prompt_yes_no "Create these now" n
|
||||
if ! ((REPLY)); then
|
||||
say "Nothing was created."
|
||||
return 0
|
||||
fi
|
||||
confirm_reuse
|
||||
create_all
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# cli.sh - The command line: usage text and option parsing.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: usage, usage_error, parse_args
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ${0##*/} [--apply] [--config FILE] [--env FILE]
|
||||
${0##*/} --help | --version
|
||||
EOF
|
||||
}
|
||||
|
||||
usage_error() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit "$EXIT_USAGE"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--apply)
|
||||
IS_APPLY=1
|
||||
shift
|
||||
;;
|
||||
--config)
|
||||
(($# >= 2)) || usage_error "--config needs a file"
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--env)
|
||||
(($# >= 2)) || usage_error "--env needs a file"
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
sed -n '2,/^set -Eeuo/p' "$SCRIPT_FILE" | sed -e '$d' -e 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
exit 0
|
||||
;;
|
||||
*) usage_error "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# shellcheck shell=bash
|
||||
# constants.sh - Constants and the shared state of a run.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# The state variables are read and written by the other library files; this
|
||||
# is the one place that declares them.
|
||||
# shellcheck disable=SC2034 # read and written by the other library files
|
||||
|
||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||
readonly VERSION="0.2.0"
|
||||
readonly EXIT_FAILURE=1
|
||||
readonly EXIT_USAGE=2
|
||||
readonly MAX_VALUE_LENGTH=2048
|
||||
readonly MAX_DESCRIPTION_LENGTH=350
|
||||
readonly HTTP_TIMEOUT_SECONDS=30
|
||||
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
|
||||
readonly DEFAULT_SSH_PORT=10022
|
||||
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
||||
readonly DEFAULT_BRANCH="main"
|
||||
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror")
|
||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
|
||||
GITEA_SSH_PORT MIRROR_INTERVAL)
|
||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||
|
||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
TMP_DIR=""
|
||||
HAS_JQ=0
|
||||
HTTP_STATUS=0
|
||||
HTTP_BODY_FILE=""
|
||||
HTTP_ERROR=""
|
||||
REPLY=""
|
||||
IS_APPLY=0
|
||||
IS_CREATION_STARTED=0
|
||||
SECRET_VALUES=()
|
||||
TEMP_FILES=()
|
||||
declare -A CONFIG=()
|
||||
declare -A CREDENTIALS=()
|
||||
declare -A PROJECT=()
|
||||
# Facts found by the preflight checks (logins, owner kinds, repository state).
|
||||
declare -A STATE=()
|
||||
# Outcome of each step in PLAN_STEPS, for the final report.
|
||||
declare -A STEP_STATUS=()
|
||||
declare -A STEP_DETAIL=()
|
||||
@@ -0,0 +1,28 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# hosts.sh - Names and links of the repositories on each host.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_reused, repo_owner, repo_url
|
||||
|
||||
# is_reused HOST: succeed if the existing repository on HOST will be reused.
|
||||
is_reused() {
|
||||
[[ ${STATE[reuse_$1]:-0} == 1 ]]
|
||||
}
|
||||
|
||||
repo_owner() {
|
||||
if [[ $1 == gitea ]]; then
|
||||
printf '%s' "${PROJECT[gitea_owner]}"
|
||||
else
|
||||
printf '%s' "${PROJECT[github_owner]}"
|
||||
fi
|
||||
}
|
||||
|
||||
repo_url() {
|
||||
if [[ $1 == gitea ]]; then
|
||||
printf '%s/%s/%s' "${CONFIG[GITEA_URL]}" "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
else
|
||||
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# http.sh - The one safe place that runs curl: tokens stay off the command line.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: describe_http_status, describe_curl_error, http_request
|
||||
|
||||
describe_http_status() {
|
||||
case "$1" in
|
||||
401) printf 'authentication failed: the token is missing, expired or invalid' ;;
|
||||
403) printf 'the token is valid but not allowed to do this (check its scopes)' ;;
|
||||
404) printf 'not found (check the name, the owner and the token access)' ;;
|
||||
409 | 422) printf 'rejected (the name may already exist or be invalid)' ;;
|
||||
429) printf 'rate limited; wait and try again' ;;
|
||||
5??) printf 'the server reported an error; try again later' ;;
|
||||
*) printf 'unexpected HTTP status %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
describe_curl_error() {
|
||||
case "$1" in
|
||||
6) printf 'could not resolve the host name' ;;
|
||||
7) printf 'could not connect' ;;
|
||||
28) printf 'the request timed out' ;;
|
||||
35 | 51 | 58 | 60) printf 'the TLS connection failed' ;;
|
||||
*) printf 'curl failed with exit code %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# http_request METHOD URL SCHEME TOKEN [BODY]
|
||||
# SCHEME is "token" (Gitea) or "bearer" (GitHub). The token goes into a
|
||||
# private curl config file, never onto the command line where other users
|
||||
# could see it. Redirects are not followed, so the token is only ever sent
|
||||
# to the host named in URL. On success HTTP_STATUS and HTTP_BODY_FILE are
|
||||
# set; on a network failure the function returns 1 with HTTP_ERROR set.
|
||||
# shellcheck disable=SC2034 # HTTP_* are results read by the callers
|
||||
http_request() {
|
||||
local method="$1" url="$2" scheme="$3" token="$4" body="${5:-}"
|
||||
local header config_file body_file out_file host curl_status=0
|
||||
local data_args=()
|
||||
[[ $method =~ ^(GET|POST|PUT|PATCH|DELETE)$ ]] ||
|
||||
die "internal error: unsupported HTTP method"
|
||||
is_valid_request_url "$url" ||
|
||||
die "refusing to call an invalid or non-https URL"
|
||||
is_valid_token "$token" || die "refusing to send a malformed token"
|
||||
case "$scheme" in
|
||||
token) header="Authorization: token $token" ;;
|
||||
bearer) header="Authorization: Bearer $token" ;;
|
||||
*) die "internal error: unknown authentication scheme" ;;
|
||||
esac
|
||||
make_temp_file
|
||||
config_file="$REPLY"
|
||||
make_temp_file
|
||||
out_file="$REPLY"
|
||||
{
|
||||
printf 'url = "%s"\n' "$url"
|
||||
printf 'request = "%s"\n' "$method"
|
||||
printf 'header = "%s"\n' "$header"
|
||||
printf 'header = "Accept: application/json"\n'
|
||||
printf 'header = "User-Agent: %s/%s"\n' "$PROJECT_NAME" "$VERSION"
|
||||
} >"$config_file"
|
||||
if [[ -n $body ]]; then
|
||||
make_temp_file
|
||||
body_file="$REPLY"
|
||||
printf '%s' "$body" >"$body_file"
|
||||
printf 'header = "Content-Type: application/json"\n' >>"$config_file"
|
||||
data_args=(--data-binary "@$body_file")
|
||||
fi
|
||||
# curl's own error text is dropped: the exit code is mapped to a message
|
||||
# that never contains the request.
|
||||
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
||||
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
||||
--config "$config_file" "${data_args[@]}" 2>/dev/null)" || curl_status=$?
|
||||
# The configuration file holds the token and the body may hold another one
|
||||
# (the mirror password): remove both now instead of at exit.
|
||||
rm -f -- "$config_file" ${body_file:+"$body_file"}
|
||||
if ((curl_status != 0)); then
|
||||
host="${url#https://}"
|
||||
host="${host%%/*}"
|
||||
HTTP_STATUS=0
|
||||
HTTP_ERROR="could not reach $host: $(describe_curl_error "$curl_status")"
|
||||
return 1
|
||||
fi
|
||||
HTTP_BODY_FILE="$out_file"
|
||||
HTTP_ERROR=""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# json.sh - Reading and writing the small amount of JSON the script needs.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: json_escape, json_get, json_has_value, json_values
|
||||
|
||||
# json_escape TEXT: escape TEXT for use inside a JSON string.
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/\\n}"
|
||||
text="${text//$'\r'/\\r}"
|
||||
text="${text//$'\t'/\\t}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# json_get FILE KEY: print the string, number or boolean value of KEY.
|
||||
# With jq only the top-level key is read. Without jq the first occurrence of
|
||||
# the key anywhere in the file is used, which is enough for the flat fields
|
||||
# the GitHub and Gitea APIs return (name, id, html_url, ...).
|
||||
json_get() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
if ((HAS_JQ)); then
|
||||
# jq on Windows ends lines with CRLF; strip the CR so values stay clean.
|
||||
jq -r --arg key "$key" \
|
||||
'if has($key) and .[$key] != null then .[$key] | tostring else empty end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\(\"[^\"]*\"\|[0-9][0-9]*\|true\|false\)" "$file" || true; } |
|
||||
head -n 1 |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
# json_has_value FILE KEY VALUE: succeed if the file holds "KEY": "VALUE",
|
||||
# written with or without a space after the colon.
|
||||
json_has_value() {
|
||||
local file="$1" key="$2" value="$3"
|
||||
grep -Fq "\"$key\":\"$value\"" "$file" ||
|
||||
grep -Fq "\"$key\": \"$value\"" "$file"
|
||||
}
|
||||
|
||||
# json_values FILE KEY: print every string value of KEY, one per line.
|
||||
json_values() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" || true; } |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# mirror.sh - The Gitea to GitHub push mirror: create, verify, first sync.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: mirror_field, mirror_path, mirror_address, mirror_exists, create_mirror, verify_mirror, request_first_sync, configure_mirror
|
||||
|
||||
# mirror_field FILE ADDRESS FIELD: print FIELD of the push mirror whose
|
||||
# remote_address is ADDRESS. Without jq the first mirror in the list is used,
|
||||
# which is the only one on a repository this script has just created.
|
||||
mirror_field() {
|
||||
local file="$1" address="$2" field="$3"
|
||||
if ((HAS_JQ)); then
|
||||
jq -r --arg address "$address" --arg field "$field" \
|
||||
'[.[] | select(.remote_address == $address)][0]
|
||||
| if . == null or .[$field] == null then empty else .[$field] | tostring end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
json_get "$file" "$field"
|
||||
fi
|
||||
}
|
||||
|
||||
mirror_path() {
|
||||
printf '/repos/%s/%s/push_mirrors' "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
mirror_address() {
|
||||
printf '%s/%s/%s.git' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
# mirror_exists ADDRESS: succeed if Gitea already pushes to ADDRESS.
|
||||
mirror_exists() {
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
expect_status "cannot list the push mirrors of the Gitea repository" 200
|
||||
json_has_value "$HTTP_BODY_FILE" remote_address "$1"
|
||||
}
|
||||
|
||||
create_mirror() {
|
||||
local address="$1" body
|
||||
# The GitHub token is the password of the mirror; the body file that holds
|
||||
# it is removed as soon as the request has been sent.
|
||||
body="$(printf '{"remote_address":"%s","remote_username":"%s","remote_password":"%s","interval":"%s","sync_on_commit":true}' \
|
||||
"$address" "${STATE[github_login]}" "${CREDENTIALS[GITHUB_PAT]}" \
|
||||
"${CONFIG[MIRROR_INTERVAL]}")"
|
||||
api_call gitea POST "$(mirror_path)" "$body"
|
||||
expect_status "cannot create the push mirror (push mirrors may be switched off on the Gitea server, the interval may be shorter than the server allows, or the GitHub token may not push to the new repository)" 200 201
|
||||
}
|
||||
|
||||
# verify_mirror ADDRESS: read the mirror back and report what Gitea applied.
|
||||
verify_mirror() {
|
||||
local address="$1" on_commit
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
expect_status "cannot read the push mirror back from Gitea" 200
|
||||
json_has_value "$HTTP_BODY_FILE" remote_address "$address" ||
|
||||
die "Gitea did not list the push mirror after creating it"
|
||||
on_commit="$(mirror_field "$HTTP_BODY_FILE" "$address" sync_on_commit)"
|
||||
if [[ $on_commit != true ]]; then
|
||||
warn "Gitea did not apply sync_on_commit (a known server issue): the mirror syncs every ${CONFIG[MIRROR_INTERVAL]}, not on every commit. Switch it on in the repository settings if you need it."
|
||||
STEP_DETAIL["Push mirror"]="(syncs every ${CONFIG[MIRROR_INTERVAL]}, not on every commit)"
|
||||
fi
|
||||
}
|
||||
|
||||
# request_first_sync ADDRESS: ask Gitea for a first push and report an error
|
||||
# it records. A failure here is a warning: the mirror retries by itself.
|
||||
request_first_sync() {
|
||||
local address="$1" last_error
|
||||
api_call gitea POST "$(mirror_path)-sync"
|
||||
if [[ $HTTP_STATUS != 200 && $HTTP_STATUS != 204 ]]; then
|
||||
warn "could not ask Gitea for the first sync (HTTP $HTTP_STATUS); it runs at the next interval"
|
||||
return 0
|
||||
fi
|
||||
sleep "${REPOFOUNDRY_SYNC_WAIT:-3}"
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
if [[ $HTTP_STATUS == 200 ]]; then
|
||||
last_error="$(mirror_field "$HTTP_BODY_FILE" "$address" last_error)"
|
||||
if [[ -n $last_error ]]; then
|
||||
warn "the first mirror sync reported: ${last_error:0:200}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
configure_mirror() {
|
||||
local label="Push mirror" address
|
||||
address="$(mirror_address)"
|
||||
begin_step "$label"
|
||||
if mirror_exists "$address"; then
|
||||
finish_step "$label" "reused" "Gitea -> $address"
|
||||
else
|
||||
create_mirror "$address"
|
||||
finish_step "$label" "created" "Gitea -> $address"
|
||||
verify_mirror "$address"
|
||||
fi
|
||||
request_first_sync "$address"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# output.sh - Messages for the user: output, warnings, errors, and redaction of secrets.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: redact, say, warn, die, on_error
|
||||
|
||||
# redact TEXT: print TEXT with every known secret value replaced.
|
||||
redact() {
|
||||
local text="$1" secret
|
||||
for secret in "${SECRET_VALUES[@]}"; do
|
||||
if [[ -n $secret ]]; then
|
||||
text="${text//"$secret"/[redacted]}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$(redact "$*")"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$(redact "$*")" >&2
|
||||
}
|
||||
|
||||
# die [--code N] MESSAGE: print "error: MESSAGE" and exit (default code 1).
|
||||
die() {
|
||||
local code=$EXIT_FAILURE
|
||||
if [[ ${1:-} == --code ]]; then
|
||||
code="$2"
|
||||
shift 2
|
||||
fi
|
||||
printf 'error: %s\n' "$(redact "$*")" >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
# on_error LINE: report an unexpected failure without echoing the command,
|
||||
# because a command line could contain a value that must stay private.
|
||||
on_error() {
|
||||
printf 'error: unexpected failure near line %s of %s\n' "$1" "${0##*/}" >&2
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# plan.sh - Printing what the script is about to do.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: print_plan
|
||||
|
||||
print_plan() {
|
||||
local gitea_action github_action origin_note
|
||||
say ""
|
||||
say "Plan:"
|
||||
if ((STATE[reuse_gitea])); then
|
||||
gitea_action="reuse the existing repository (you will be asked to confirm)"
|
||||
elif ((PROJECT[has_github])); then
|
||||
gitea_action="create (${PROJECT[visibility]}) with the $AGPL_LICENSE_KEY license"
|
||||
else
|
||||
gitea_action="create (${PROJECT[visibility]}), empty"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s %s' "Gitea repository" "$gitea_action" "$(repo_url gitea)")"
|
||||
if ((PROJECT[has_github])); then
|
||||
if ((STATE[reuse_github])); then
|
||||
github_action="reuse the existing empty repository (you will be asked to confirm)"
|
||||
else
|
||||
github_action="create (${PROJECT[visibility]}), empty"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s %s' "GitHub repository" "$github_action" "$(repo_url github)")"
|
||||
say "$(printf ' %-18s: %s' "Push mirror" "Gitea -> GitHub every ${CONFIG[MIRROR_INTERVAL]}")"
|
||||
else
|
||||
say "$(printf ' %-18s: %s' "GitHub repository" "not used")"
|
||||
say "$(printf ' %-18s: %s' "Push mirror" "not used")"
|
||||
fi
|
||||
if ((STATE[is_ssh_ok])); then
|
||||
origin_note="SSH (the SSH test passed)"
|
||||
else
|
||||
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note, in a later phase")"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# preflight.sh - Read-only checks of both hosts before anything is created.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: check_gitea_organization, check_gitea_license, inspect_repository, preflight_gitea, check_github_organization, preflight_github, test_gitea_ssh, decide_existing_repositories, run_preflight
|
||||
|
||||
check_gitea_organization() {
|
||||
local org="$1" login="$2"
|
||||
api_call gitea GET "/orgs/$org"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
die "Gitea owner '$org' is neither your account ($login) nor an organization the token can see"
|
||||
fi
|
||||
expect_status "cannot look up the Gitea organization '$org'" 200
|
||||
api_call gitea GET "/users/$login/orgs/$org/permissions"
|
||||
expect_status "cannot read your permissions in the Gitea organization '$org'" 200
|
||||
if [[ $(json_get "$HTTP_BODY_FILE" can_create_repository) != true ]]; then
|
||||
die "you may not create repositories in the Gitea organization '$org'"
|
||||
fi
|
||||
}
|
||||
|
||||
check_gitea_license() {
|
||||
api_call gitea GET /licenses
|
||||
expect_status "cannot list the licenses of the Gitea server" 200
|
||||
json_has_value "$HTTP_BODY_FILE" key "$AGPL_LICENSE_KEY" ||
|
||||
die "the Gitea server does not offer the $AGPL_LICENSE_KEY license"
|
||||
}
|
||||
|
||||
# inspect_repository HOST: record in STATE[HOST_repo] whether the repository
|
||||
# is free (does not exist), empty, license_only or not_empty.
|
||||
inspect_repository() {
|
||||
local host="$1" owner name names
|
||||
owner="$(repo_owner "$host")"
|
||||
name="${PROJECT[name]}"
|
||||
api_call "$host" GET "/repos/$owner/$name"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
STATE[${host}_repo]="free"
|
||||
return 0
|
||||
fi
|
||||
expect_status "cannot look up the $(host_label "$host") repository $owner/$name" 200
|
||||
api_call "$host" GET "/repos/$owner/$name/contents"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
STATE[${host}_repo]="empty"
|
||||
return 0
|
||||
fi
|
||||
expect_status "cannot read the contents of the $(host_label "$host") repository $owner/$name" 200
|
||||
names="$(json_values "$HTTP_BODY_FILE" name)"
|
||||
case "$names" in
|
||||
"") STATE[${host}_repo]="empty" ;;
|
||||
LICENSE) STATE[${host}_repo]="license_only" ;;
|
||||
*) STATE[${host}_repo]="not_empty" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
preflight_gitea() {
|
||||
local owner="${PROJECT[gitea_owner]}" login
|
||||
api_call gitea GET /user
|
||||
expect_status "Gitea rejected the token" 200
|
||||
login="$(json_get "$HTTP_BODY_FILE" login)"
|
||||
[[ -n $login ]] || die "Gitea did not say which account the token belongs to"
|
||||
STATE[gitea_login]="$login"
|
||||
if is_same_name "$owner" "$login"; then
|
||||
STATE[gitea_owner_kind]="user"
|
||||
else
|
||||
check_gitea_organization "$owner" "$login"
|
||||
STATE[gitea_owner_kind]="organization"
|
||||
fi
|
||||
if ((PROJECT[has_github])); then
|
||||
check_gitea_license
|
||||
fi
|
||||
inspect_repository gitea
|
||||
}
|
||||
|
||||
check_github_organization() {
|
||||
local org="$1" login="$2"
|
||||
api_call github GET "/user/memberships/orgs/$org"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
die "GitHub owner '$org' is neither your account ($login) nor an organization you belong to (or the token lacks the read:org scope)"
|
||||
fi
|
||||
expect_status "cannot read your membership of the GitHub organization '$org'" 200
|
||||
if [[ $(json_get "$HTTP_BODY_FILE" state) != active ]]; then
|
||||
die "your membership of the GitHub organization '$org' is not active"
|
||||
fi
|
||||
}
|
||||
|
||||
preflight_github() {
|
||||
local owner="${PROJECT[github_owner]}" configured login
|
||||
configured="${CREDENTIALS[GITHUB_USER]:-}"
|
||||
api_call github GET /user
|
||||
expect_status "GitHub rejected the token" 200
|
||||
login="$(json_get "$HTTP_BODY_FILE" login)"
|
||||
[[ -n $login ]] || die "GitHub did not say which account the token belongs to"
|
||||
STATE[github_login]="$login"
|
||||
if [[ -n $configured ]] && ! is_same_name "$configured" "$login"; then
|
||||
warn "GITHUB_USER is '$configured' but the token belongs to '$login'; the mirror will use '$login'"
|
||||
fi
|
||||
if is_same_name "$owner" "$login"; then
|
||||
STATE[github_owner_kind]="user"
|
||||
else
|
||||
check_github_organization "$owner" "$login"
|
||||
STATE[github_owner_kind]="organization"
|
||||
fi
|
||||
inspect_repository github
|
||||
}
|
||||
|
||||
# The SSH result decides later whether origin uses SSH or HTTPS. Without ssh
|
||||
# or without access it is simply "not passed"; it never stops the run.
|
||||
test_gitea_ssh() {
|
||||
local host port output status=0
|
||||
host="${CONFIG[GITEA_URL]#https://}"
|
||||
host="${host%%/*}"
|
||||
host="${host%%:*}"
|
||||
port="${CONFIG[GITEA_SSH_PORT]}"
|
||||
STATE[is_ssh_ok]=0
|
||||
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
STATE[ssh_note]="not tested (ssh is not installed)"
|
||||
return 0
|
||||
fi
|
||||
output="$(ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=5 \
|
||||
-o StrictHostKeyChecking=yes -T "git@$host" 2>&1)" || status=$?
|
||||
if ((status == 0)) || [[ $output == *"successfully authenticated"* ]]; then
|
||||
STATE[is_ssh_ok]=1
|
||||
STATE[ssh_note]="passed"
|
||||
fi
|
||||
}
|
||||
|
||||
# decide_existing_repositories: a repository that already exists may only be
|
||||
# reused when it is empty (Gitea: or holds just the license this script adds).
|
||||
decide_existing_repositories() {
|
||||
local host owner state
|
||||
for host in gitea github; do
|
||||
STATE[reuse_$host]=0
|
||||
if [[ $host == github ]] && ! ((PROJECT[has_github])); then
|
||||
continue
|
||||
fi
|
||||
owner="$(repo_owner "$host")"
|
||||
state="${STATE[${host}_repo]}"
|
||||
case "$state" in
|
||||
free) ;;
|
||||
empty) STATE[reuse_$host]=1 ;;
|
||||
license_only)
|
||||
if [[ $host == gitea ]] && ((PROJECT[has_github])); then
|
||||
STATE[reuse_$host]=1
|
||||
else
|
||||
die "the $(host_label "$host") repository $owner/${PROJECT[name]} already exists and has content; choose another name or remove it first"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
die "the $(host_label "$host") repository $owner/${PROJECT[name]} already exists and has content; choose another name or remove it first"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_preflight() {
|
||||
say ""
|
||||
say "Checking the hosts (read-only requests)..."
|
||||
preflight_gitea
|
||||
if ((PROJECT[has_github])); then
|
||||
preflight_github
|
||||
fi
|
||||
test_gitea_ssh
|
||||
decide_existing_repositories
|
||||
say "All checks passed."
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# project.sh - The details of the project being created: asking for them and showing them.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: collect_project_details, yes_no, credential_state, print_summary
|
||||
|
||||
collect_project_details() {
|
||||
prompt_value "Repository name" "" is_valid_repo_name \
|
||||
"use letters, digits, '.', '_' or '-' (at most 100), not ending in .git"
|
||||
PROJECT[name]="$REPLY"
|
||||
prompt_value "Description (optional)" "" is_valid_description \
|
||||
"at most $MAX_DESCRIPTION_LENGTH characters and no control characters"
|
||||
PROJECT[description]="$REPLY"
|
||||
prompt_choice "Visibility" private private public
|
||||
PROJECT[visibility]="$REPLY"
|
||||
prompt_value "Gitea owner (user or organization)" "" is_valid_gitea_owner \
|
||||
"use letters, digits, '.', '_' or '-' (at most 39)"
|
||||
PROJECT[gitea_owner]="$REPLY"
|
||||
prompt_yes_no "Also create a GitHub repository (applies the AGPL license)" y
|
||||
PROJECT[has_github]="$REPLY"
|
||||
PROJECT[github_owner]=""
|
||||
if ((PROJECT[has_github])); then
|
||||
prompt_value "GitHub owner (user or organization)" \
|
||||
"${CREDENTIALS[GITHUB_USER]:-}" is_valid_github_owner \
|
||||
"use letters, digits or '-' (at most 39)"
|
||||
PROJECT[github_owner]="$REPLY"
|
||||
fi
|
||||
prompt_value "Local directory" "./${PROJECT[name]}" is_valid_directory \
|
||||
"must not be empty, start with '-' or contain control characters"
|
||||
PROJECT[directory]="$REPLY"
|
||||
prompt_yes_no "Enable the plan gate" n
|
||||
PROJECT[is_plan_gate_enabled]="$REPLY"
|
||||
}
|
||||
|
||||
yes_no() {
|
||||
if (($1)); then
|
||||
printf 'yes'
|
||||
else
|
||||
printf 'no'
|
||||
fi
|
||||
}
|
||||
|
||||
credential_state() {
|
||||
if [[ -n ${CREDENTIALS[$1]:-} ]]; then
|
||||
printf 'set'
|
||||
else
|
||||
printf 'not set'
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
say ""
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
say "Collected details:"
|
||||
say " Repository : ${PROJECT[name]} (${PROJECT[visibility]})"
|
||||
say " Description : ${PROJECT[description]:-(none)}"
|
||||
say " Gitea : ${CONFIG[GITEA_URL]}/${PROJECT[gitea_owner]}/${PROJECT[name]}"
|
||||
if ((PROJECT[has_github])); then
|
||||
say " GitHub : ${CONFIG[GITHUB_WEB_URL]}/${PROJECT[github_owner]}/${PROJECT[name]} (AGPL license applied)"
|
||||
else
|
||||
say " GitHub : not used"
|
||||
fi
|
||||
say " Directory : ${PROJECT[directory]}"
|
||||
say " Plan gate : $(yes_no "${PROJECT[is_plan_gate_enabled]}")"
|
||||
say "Credentials : GITEA_TOKEN $(credential_state GITEA_TOKEN)," \
|
||||
"GITHUB_PAT $(credential_state GITHUB_PAT)"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# prompts.sh - Interactive questions with validation of every answer.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: prompt_value, prompt_choice, prompt_yes_no
|
||||
|
||||
# prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the
|
||||
# answer; the accepted answer is returned in REPLY.
|
||||
prompt_value() {
|
||||
local label="$1" default="$2" validator="$3" hint="$4" answer
|
||||
while true; do
|
||||
if [[ -n $default ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" >&2
|
||||
else
|
||||
printf '%s: ' "$label" >&2
|
||||
fi
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
if "$validator" "$answer"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: $hint"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_choice LABEL DEFAULT CHOICE...: the answer is returned in REPLY.
|
||||
prompt_choice() {
|
||||
local label="$1" default="$2" answer
|
||||
shift 2
|
||||
while true; do
|
||||
printf '%s (%s) [%s]: ' "$label" "$(IFS=/ && echo "$*")" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
answer="${answer,,}"
|
||||
if in_list "$answer" "$@"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: choose one of $*"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_yes_no LABEL DEFAULT: DEFAULT is y or n; REPLY is 1 (yes) or 0 (no).
|
||||
prompt_yes_no() {
|
||||
local label="$1" default="$2" answer
|
||||
while true; do
|
||||
printf '%s (y/n) [%s]: ' "$label" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
case "${answer,,}" in
|
||||
y | yes)
|
||||
REPLY=1
|
||||
return 0
|
||||
;;
|
||||
n | no)
|
||||
REPLY=0
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
warn "invalid $label: answer y or n"
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# repositories.sh - Creating the GitHub and Gitea repositories.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: repo_body, create_repository
|
||||
|
||||
# repo_body HOST: the JSON body that creates the repository on HOST.
|
||||
repo_body() {
|
||||
local host="$1" description private=true extra=""
|
||||
description="$(json_escape "${PROJECT[description]}")"
|
||||
if [[ ${PROJECT[visibility]} != private ]]; then
|
||||
private=false
|
||||
fi
|
||||
if [[ $host == github ]]; then
|
||||
printf '{"name":"%s","description":"%s","private":%s,"auto_init":false}' \
|
||||
"${PROJECT[name]}" "$description" "$private"
|
||||
return 0
|
||||
fi
|
||||
if ((PROJECT[has_github])); then
|
||||
extra=',"auto_init":true,"license":"'"$AGPL_LICENSE_KEY"'"'
|
||||
else
|
||||
extra=',"auto_init":false'
|
||||
fi
|
||||
printf '{"name":"%s","description":"%s","private":%s,"default_branch":"%s"%s}' \
|
||||
"${PROJECT[name]}" "$description" "$private" "$DEFAULT_BRANCH" "$extra"
|
||||
}
|
||||
|
||||
# create_repository HOST: create the repository, or reuse the existing one.
|
||||
create_repository() {
|
||||
local host="$1" label owner path
|
||||
label="$(host_label "$host") repository"
|
||||
owner="$(repo_owner "$host")"
|
||||
if is_reused "$host"; then
|
||||
finish_step "$label" "reused" "$(repo_url "$host")"
|
||||
return 0
|
||||
fi
|
||||
begin_step "$label"
|
||||
path="/user/repos"
|
||||
if [[ ${STATE[${host}_owner_kind]} == organization ]]; then
|
||||
path="/orgs/$owner/repos"
|
||||
fi
|
||||
api_call "$host" POST "$path" "$(repo_body "$host")"
|
||||
expect_status "cannot create the $label" 201
|
||||
finish_step "$label" "created" "$(repo_url "$host")"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# steps.sh - The outcome of each step and the final report of a run.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: init_steps, begin_step, finish_step, report_outcome
|
||||
|
||||
init_steps() {
|
||||
local label
|
||||
for label in "${PLAN_STEPS[@]}"; do
|
||||
STEP_STATUS[$label]="not attempted"
|
||||
STEP_DETAIL[$label]=""
|
||||
done
|
||||
if ! ((PROJECT[has_github])); then
|
||||
STEP_STATUS["GitHub repository"]="not used"
|
||||
STEP_STATUS["Push mirror"]="not used"
|
||||
fi
|
||||
}
|
||||
|
||||
# begin_step LABEL marks the step failed until finish_step says otherwise, so
|
||||
# any stop in the middle of a step is reported as a failure of that step.
|
||||
begin_step() {
|
||||
STEP_STATUS[$1]="FAILED"
|
||||
STEP_DETAIL[$1]=""
|
||||
}
|
||||
|
||||
finish_step() {
|
||||
STEP_STATUS[$1]="$2"
|
||||
STEP_DETAIL[$1]="${3:-}"
|
||||
}
|
||||
|
||||
report_outcome() {
|
||||
local code="$1" label
|
||||
say ""
|
||||
if ((code == 0)); then
|
||||
say "Done. This is what exists now:"
|
||||
else
|
||||
say "The run stopped before it finished. This is what exists now:"
|
||||
fi
|
||||
for label in "${PLAN_STEPS[@]}"; do
|
||||
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
|
||||
done
|
||||
if ((code == 0)); then
|
||||
say "The local project with the framework is created by a later phase."
|
||||
else
|
||||
say "To continue: fix the problem named above and run the same command again with --apply."
|
||||
say "A repository this run created is still empty (or holds only the license), so the next run offers to reuse it."
|
||||
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface."
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# temp.sh - Private temporary files and their cleanup (never a recursive delete).
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: cleanup, setup_temp_dir, make_temp_file
|
||||
|
||||
# Files are removed one by one and the directory with rmdir: a recursive
|
||||
# delete is never needed and never used.
|
||||
cleanup() {
|
||||
local file
|
||||
for file in "${TEMP_FILES[@]}"; do
|
||||
rm -f -- "$file"
|
||||
done
|
||||
if [[ -n $TMP_DIR && -d $TMP_DIR ]]; then
|
||||
# rmdir fails only if something unexpected is left inside; leave it
|
||||
# rather than delete files this script did not create.
|
||||
rmdir -- "$TMP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
setup_temp_dir() {
|
||||
TMP_DIR="$(umask 077 && mktemp -d "${TMPDIR:-/tmp}/repofoundry.XXXXXX")"
|
||||
}
|
||||
|
||||
# make_temp_file: create a private file in TMP_DIR and return it in REPLY.
|
||||
make_temp_file() {
|
||||
REPLY="$(umask 077 && mktemp "$TMP_DIR/file.XXXXXX")"
|
||||
TEMP_FILES+=("$REPLY")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# tools.sh - Checking that the required tools are installed.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: check_tools
|
||||
|
||||
check_tools() {
|
||||
local tool
|
||||
local missing=()
|
||||
for tool in git curl mktemp; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing+=("$tool")
|
||||
fi
|
||||
done
|
||||
if ((${#missing[@]} > 0)); then
|
||||
die "required tool(s) not found: ${missing[*]}. Install them and try again."
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HAS_JQ=1
|
||||
else
|
||||
HAS_JQ=0
|
||||
warn "jq not found; using the built-in JSON reader (install jq for stricter parsing)"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# util.sh - Small string and list helpers with no knowledge of the project.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: trim, in_list, has_control_character, is_same_name
|
||||
|
||||
trim() {
|
||||
local text="$1"
|
||||
text="${text#"${text%%[![:space:]]*}"}"
|
||||
text="${text%"${text##*[![:space:]]}"}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# in_list NEEDLE ITEM...: succeed if NEEDLE equals one of the items.
|
||||
in_list() {
|
||||
local needle="$1" item
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ $item == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_control_character() {
|
||||
[[ $1 == *[[:cntrl:]]* ]]
|
||||
}
|
||||
|
||||
is_same_name() {
|
||||
[[ ${1,,} == "${2,,}" ]]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# validate.sh - Validators for names, URLs, tokens, ports and intervals.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
|
||||
|
||||
is_valid_repo_name() {
|
||||
local name="$1"
|
||||
[[ $name =~ ^[A-Za-z0-9._-]{1,100}$ ]] || return 1
|
||||
[[ $name != . && $name != .. && $name != *.git ]]
|
||||
}
|
||||
|
||||
is_valid_gitea_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]]
|
||||
}
|
||||
|
||||
is_valid_github_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ ]]
|
||||
}
|
||||
|
||||
is_valid_description() {
|
||||
((${#1} <= MAX_DESCRIPTION_LENGTH)) && ! has_control_character "$1"
|
||||
}
|
||||
|
||||
is_valid_directory() {
|
||||
local path="$1"
|
||||
[[ -n $path && ${#path} -le 4096 && $path != -* ]] &&
|
||||
! has_control_character "$path"
|
||||
}
|
||||
|
||||
# https URL without user info, query or fragment, so it can never carry a
|
||||
# credential.
|
||||
is_valid_base_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Like is_valid_base_url but a query string is allowed (for API requests).
|
||||
is_valid_request_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?(\?[A-Za-z0-9._~%+=&,-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Access tokens: no quotes, backslashes or whitespace, so a token cannot
|
||||
# break out of the curl configuration it is written to.
|
||||
is_valid_token() {
|
||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
||||
}
|
||||
|
||||
is_valid_port() {
|
||||
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
|
||||
}
|
||||
|
||||
# A Go duration such as 10m0s or 8h0m0s, the form Gitea expects.
|
||||
is_valid_interval() {
|
||||
[[ -n $1 && $1 =~ ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ ]]
|
||||
}
|
||||
|
||||
normalize_url() {
|
||||
local url="$1"
|
||||
while [[ $url == */ ]]; do
|
||||
url="${url%/}"
|
||||
done
|
||||
printf '%s' "$url"
|
||||
}
|
||||
Reference in New Issue
Block a user