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:
2026-10-05 15:29:10 +08:00
co-authored by Claude Sonnet 5.5
parent 903c948d85
commit 736cdb42fc
30 changed files with 2269 additions and 668 deletions
+99 -636
View File
@@ -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
+61
View File
@@ -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"
}
+46
View File
@@ -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
}
+50
View File
@@ -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
}
+164
View File
@@ -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"
}
+46
View 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=()
+28
View File
@@ -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
}
+87
View File
@@ -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=""
}
+55
View File
@@ -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/'
}
+95
View File
@@ -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"
}
+43
View File
@@ -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
}
+39
View File
@@ -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")"
}
+167
View File
@@ -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."
}
+69
View File
@@ -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)"
}
+68
View File
@@ -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
}
+47
View File
@@ -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")"
}
+51
View File
@@ -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
}
+31
View File
@@ -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")
}
+26
View File
@@ -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
}
+34
View File
@@ -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,,}" ]]
}
+67
View File
@@ -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"
}