diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8484116 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9328d14 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# RepoFoundry credentials. Placeholders only: never put a real value in this +# file or commit one. +# +# Copy this file to .env, fill in the values and keep it private +# (chmod 600 .env on Linux and macOS). .env is ignored by git. The file is +# read as plain KEY=VALUE lines and never executed. Values may be wrapped in +# single or double quotes. + +# GitHub personal access token. Needed only when you choose to create a +# GitHub repository. It creates the repository and is also the password of the +# Gitea push mirror, so it needs permission to create repositories for the +# chosen owner and to push to the new one. Prefer a fine-grained token. +GITHUB_PAT= + +# GitHub account the token belongs to. It identifies who authenticates; it is +# only a default suggestion for the owner prompt, because the repository can +# belong to an organization. +GITHUB_USER= + +# Gitea access token (required). It needs permission to create repositories +# for the chosen owner and to manage the repository's push mirror. +GITEA_TOKEN= diff --git a/.gitignore b/.gitignore index 36b13f1..075af95 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,13 @@ cython_debug/ # PyPI configuration file .pypirc + +# RepoFoundry: credentials and temporary files +.env +.env.* +!.env.example +*.tmp +*.swp +*~ +tmp/ +repofoundry.*/ diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..e4574c5 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,2 @@ +# shellcheck configuration for the RepoFoundry scripts +shell=bash diff --git a/config.env.example b/config.env.example new file mode 100644 index 0000000..394d7fe --- /dev/null +++ b/config.env.example @@ -0,0 +1,23 @@ +# RepoFoundry service addresses (not secret). +# +# Copy this file to config.env and adjust it. create-project.sh reads it as +# plain KEY=VALUE lines; it is never executed, so no shell syntax, $variables +# or command substitution works here. Credentials do NOT belong in this file: +# put them in .env (see .env.example). A credential key in this file is +# rejected. +# +# Every URL must start with https:// and must not contain a user name, a +# password, a query string or a fragment. + +# GitHub REST API base URL. +GITHUB_API_URL=https://api.github.com + +# GitHub web base URL, used for the repository links the script prints and +# for the push-mirror address. +GITHUB_WEB_URL=https://github.com + +# Gitea instance base URL. Repository links are derived from it. +GITEA_URL=https://git.tirsystem.com/ + +# Gitea REST API base URL. If you leave this out it is GITEA_URL + /api/v1. +GITEA_API_URL=https://git.tirsystem.com/api/v1 diff --git a/create-project.sh b/create-project.sh new file mode 100644 index 0000000..1670d4a --- /dev/null +++ b/create-project.sh @@ -0,0 +1,689 @@ +#!/usr/bin/env bash +# create-project.sh - set up a new project on Gitea (and optionally GitHub). +# +# 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. +# +# Usage +# create-project.sh [--config FILE] [--env FILE] +# create-project.sh --help | --version +# +# Options +# --config FILE service addresses (default: config.env next to the script) +# --env FILE credentials (default: .env next to the script) +# -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 +# .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 +# +# Requires +# bash 4.4 or later, git, curl, mktemp; jq is optional (used when present). +# +# Exit codes +# 0 success, 1 a failed check or bad input, 2 a usage error. +set -Eeuo pipefail + +if ((BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 4))); then + printf 'error: bash 4.4 or later is required (found %s)\n' "$BASH_VERSION" >&2 + 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) + +case "${BASH_SOURCE[0]}" in + */*) script_path_dir="${BASH_SOURCE[0]%/*}" ;; + *) script_path_dir="." ;; +esac +SCRIPT_DIR="$(cd "$script_path_dir" && pwd)" +readonly SCRIPT_DIR +unset script_path_dir + +CONFIG_FILE="$SCRIPT_DIR/config.env" +ENV_FILE="$SCRIPT_DIR/.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=() + +# ---------------------------------------------------------------- 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 + fi + printf 'error: %s\n' "$(redact "$*")" >&2 + exit "$code" +} + +usage() { + cat <&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)) + 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[use_github]="$REPLY" + PROJECT[github_owner]="" + if ((PROJECT[use_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[plan_gate]="$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[use_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[plan_gate]}")" + 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 +} + +main() { + trap 'on_error "$LINENO"' ERR + trap cleanup EXIT + is_valid_repo_name "$PROJECT_NAME" || + die "REPOFOUNDRY_NAME is not a valid project name" + parse_args "$@" + check_tools + setup_temp_dir + load_configuration + collect_project_details + if ((PROJECT[use_github])); then + require_github_credentials + fi + print_summary +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + main "$@" +fi diff --git a/tests/lib.sh b/tests/lib.sh new file mode 100644 index 0000000..2f120c6 --- /dev/null +++ b/tests/lib.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# lib.sh - tiny test helpers for the RepoFoundry tests (sourced, not run). +# +# Each test file defines functions named test_*; run-tests.sh calls them. +# The helpers run create-project.sh in separate bash processes with a private +# work directory and stub tools on PATH, so a test never touches the network, +# the real .env or the repository. +# +# Requires: bash 4.4 or later. + +# shellcheck disable=SC2016,SC2034 # stub and snippet text is literal on purpose; OUT, ERR and STATUS are read by the test files +REPO_ROOT="$(cd "${BASH_SOURCE[0]%/*}/.." && pwd)" +readonly REPO_ROOT +readonly SCRIPT="$REPO_ROOT/create-project.sh" + +# Distinctive fake credentials; the tests search all output for them. +readonly FAKE_GITEA_TOKEN="giteaFAKEtoken1234567890" +readonly FAKE_GITHUB_PAT="ghpFAKEtoken1234567890" + +TESTS_RUN=0 +TESTS_FAILED=0 +CURRENT_TEST="" +WORK="" +OUT="" +ERR="" +STATUS=0 + +fail() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + printf 'FAIL %s: %s\n' "$CURRENT_TEST" "$1" +} + +check() { + TESTS_RUN=$((TESTS_RUN + 1)) +} + +assert_eq() { + check + if [[ $2 != "$3" ]]; then + fail "$1: expected '$2', got '$3'" + fi +} + +assert_status() { + assert_eq "$1 (exit status)" "$2" "$3" +} + +assert_contains() { + check + if [[ $2 != *"$3"* ]]; then + fail "$1: output does not contain '$3'" + fi +} + +assert_not_contains() { + check + if [[ $2 == *"$3"* ]]; then + fail "$1: output contains '$3' but must not" + fi +} + +assert_file_exists() { + check + if [[ ! -e $2 ]]; then + fail "$1: '$2' does not exist" + fi +} + +assert_file_missing() { + check + if [[ -e $2 ]]; then + fail "$1: '$2' exists but must not" + fi +} + +# new_workdir: create a private work directory with a stub bin directory. +new_workdir() { + WORK="$(mktemp -d "${TMPDIR:-/tmp}/repofoundry-test.XXXXXX")" + mkdir -p "$WORK/bin" "$WORK/tmp" +} + +# remove_workdir: delete the work directory without a recursive rm: files +# first, then the now empty directories from the bottom up. +remove_workdir() { + if [[ -n $WORK && -d $WORK ]]; then + find "$WORK" -type f -delete + find "$WORK" -depth -type d -exec rmdir {} + + fi + WORK="" +} + +# write_fixtures: valid config.env and .env in the work directory. +write_fixtures() { + cat >"$WORK/config.env" <"$WORK/.env" <"$WORK/bin/$1" + chmod +x "$WORK/bin/$1" +} + +# write_curl_stub: a curl that records its arguments and configuration and +# answers with STUB_CURL_STATUS (default 200) and body STUB_CURL_BODY. +write_curl_stub() { + write_stub curl ' +printf "%s\n" "$@" >>"$STUB_DIR/curl.args" +out="" cfg="" +while (($# > 0)); do + case "$1" in + --output) out="$2"; shift 2 ;; + --config) cfg="$2"; shift 2 ;; + *) shift ;; + esac +done +if [[ -n $cfg ]]; then cat "$cfg" >>"$STUB_DIR/curl.config"; fi +body="${STUB_CURL_BODY:-}" +if [[ -z $body ]]; then body="{\"ok\":true}"; fi +if [[ -n $out ]]; then printf "%s" "$body" >"$out"; fi +printf "%s" "${STUB_CURL_STATUS:-200}" +exit "${STUB_CURL_EXIT:-0}"' +} + +# run_cli STDIN ARGS...: run create-project.sh with answers from STDIN (a +# string). Sets OUT, ERR and STATUS. +run_cli() { + local input="$1" + shift + STATUS=0 + PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \ + "$BASH" "$SCRIPT" "$@" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" || + STATUS=$? + OUT="$(cat "$WORK/out.txt")" + ERR="$(cat "$WORK/err.txt")" +} + +# run_lib INPUT CODE: source create-project.sh and run CODE in a fresh bash, +# so that single functions can be tested. Sets OUT, ERR and STATUS. +run_lib() { + local input="$1" + STATUS=0 + { + printf '#!/usr/bin/env bash\nsource "%s"\n' "$SCRIPT" + printf '%s\n' "$2" + } >"$WORK/snippet.sh" + PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" \ + "$BASH" "$WORK/snippet.sh" <<<"$input" >"$WORK/out.txt" 2>"$WORK/err.txt" || + STATUS=$? + OUT="$(cat "$WORK/out.txt")" + ERR="$(cat "$WORK/err.txt")" +} diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100644 index 0000000..76734b6 --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# run-tests.sh - run the RepoFoundry checks: bash -n, shellcheck, shfmt (when +# installed) and every test_* function in tests/test-*.sh. +# +# Usage +# tests/run-tests.sh [NAME-PATTERN] run only tests whose name matches +# +# Everything runs in private work directories with stub tools: no network, +# no real .env and no change to the repository. +# +# Requires: bash 4.4 or later, git; shellcheck and shfmt are used if present. +# +# Exit codes: 0 all checks passed, 1 a check failed. +set -Eeuo pipefail + +TEST_DIR="$(cd "${BASH_SOURCE[0]%/*}" && pwd)" +readonly TEST_DIR +# shellcheck source=tests/lib.sh +source "$TEST_DIR/lib.sh" + +pattern="${1:-}" +failed_checks=0 + +run_static_checks() { + printf '== static checks\n' + bash -n "$SCRIPT" || failed_checks=$((failed_checks + 1)) + if command -v shellcheck >/dev/null 2>&1; then + shellcheck "$SCRIPT" "$TEST_DIR"/*.sh || + failed_checks=$((failed_checks + 1)) + else + printf 'skipped: shellcheck is not installed\n' + fi + if command -v shfmt >/dev/null 2>&1; then + shfmt -i 2 -ci -d "$SCRIPT" "$TEST_DIR"/*.sh || + failed_checks=$((failed_checks + 1)) + else + printf 'skipped: shfmt is not installed\n' + fi +} + +run_test_file() { + local file="$1" name + # shellcheck source=/dev/null + source "$file" + while read -r name; do + if [[ -n $pattern && $name != *"$pattern"* ]]; then + continue + fi + CURRENT_TEST="$name" + new_workdir + "$name" || fail "the test stopped with an error" + remove_workdir + done < <(declare -F | awk '$3 ~ /^test_/ {print $3}') +} + +run_static_checks +for test_file in "$TEST_DIR"/test-*.sh; do + printf '== %s\n' "${test_file##*/}" + run_test_file "$test_file" + # Forget this file's tests so the next file starts clean. + while read -r name; do + unset -f "$name" + done < <(declare -F | awk '$3 ~ /^test_/ {print $3}') +done + +printf '\n%d checks, %d failed, %d static check(s) failed\n' \ + "$TESTS_RUN" "$TESTS_FAILED" "$failed_checks" +if ((TESTS_FAILED > 0 || failed_checks > 0)); then + exit 1 +fi diff --git a/tests/test-config.sh b/tests/test-config.sh new file mode 100644 index 0000000..69ddd34 --- /dev/null +++ b/tests/test-config.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# test-config.sh - tests for the config.env and .env parsing and validation +# (MIL-001 task: safe parser). Sourced by run-tests.sh. + +# parse_ok FILE-CONTENT: parse a config file and print the CONFIG entries. +# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose +parse_snippet() { + printf 'parse_env_file "%s" CONFIG_KEYS CONFIG\nfor k in "${!CONFIG[@]}"; do printf "%%s=%%s\\n" "$k" "${CONFIG[$k]}"; done | sort\n' "$WORK/c.env" +} + +test_parser_accepts_valid_file() { + printf '# comment\n\nGITEA_URL="https://a.test/"\r\nGITHUB_WEB_URL='"'"'https://b.test'"'"'\nGITEA_API_URL=https://a.test/api/v1 # inline\n' >"$WORK/c.env" + run_lib "" "$(parse_snippet)" + assert_status "valid file" 0 "$STATUS" + assert_contains "quotes stripped" "$OUT" "GITEA_URL=https://a.test/" + assert_contains "single quotes" "$OUT" "GITHUB_WEB_URL=https://b.test" + assert_contains "inline comment dropped" "$OUT" "GITEA_API_URL=https://a.test/api/v1" +} + +test_parser_rejects_bad_input() { + local case_name content expected + while IFS='|' read -r case_name content expected; do + printf '%b' "$content" >"$WORK/c.env" + run_lib "" "$(parse_snippet)" + assert_status "$case_name" 1 "$STATUS" + assert_contains "$case_name message" "$ERR" "$expected" + done <<'EOF' +unknown key|OTHER=1\n|unknown key 'OTHER' +credential in config|GITEA_TOKEN=abcdefgh12345\n|is a credential +not KEY=VALUE|just some text\n|line 1: expected KEY=VALUE +lowercase key|gitea_url=https://a.test\n|unknown key 'gitea_url' +duplicate key|GITEA_URL=https://a.test\nGITEA_URL=https://b.test\n|set twice +unbalanced quote|GITEA_URL="https://a.test\n|unbalanced +quote inside|GITEA_URL="https://a"b.test"\n|unbalanced +control character|GITEA_URL=https://a\x01b.test\n|control character +EOF +} + +test_parser_missing_file() { + run_lib "" "parse_env_file \"$WORK/none.env\" CONFIG_KEYS CONFIG" + assert_status "missing file" 1 "$STATUS" + assert_contains "missing file message" "$ERR" "cannot read" +} + +test_parser_never_executes_values() { + local marker="$WORK/pwned" + printf 'GITEA_URL=$(touch %s)\nGITHUB_WEB_URL=`touch %s`\n' "$marker" "$marker" >"$WORK/c.env" + run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG +validate_config" + assert_file_missing "command substitution not run" "$marker" + assert_status "bad value rejected" 1 "$STATUS" + printf 'GITEA_TOKEN=$(touch %s)\n' "$marker" >"$WORK/e.env" + run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS +validate_credentials" + assert_file_missing "token value not run" "$marker" + assert_status "bad token rejected" 1 "$STATUS" +} + +test_config_validation() { + local case_name url expected + while IFS='|' read -r case_name url expected; do + printf 'GITEA_URL=%s\n' "$url" >"$WORK/c.env" + run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG +validate_config" + assert_status "$case_name" 1 "$STATUS" + assert_contains "$case_name message" "$ERR" "$expected" + done <<'EOF' +plain http|http://git.example.test|https URL +user info|https://user:pw@git.example.test|https URL +query string|https://git.example.test/?token=abc|https URL +fragment|https://git.example.test/#x|https URL +spaces|https://git.example.test/a b|https URL +EOF + printf '# nothing\n' >"$WORK/c.env" + run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG +validate_config" + assert_status "GITEA_URL missing" 1 "$STATUS" + assert_contains "GITEA_URL missing message" "$ERR" "GITEA_URL is missing" +} + +test_config_defaults_and_normalizing() { + printf 'GITEA_URL=https://git.example.test///\n' >"$WORK/c.env" + run_lib "" "parse_env_file \"$WORK/c.env\" CONFIG_KEYS CONFIG +validate_config +printf '%s\n' \"\${CONFIG[GITEA_URL]}\" \"\${CONFIG[GITEA_API_URL]}\" \"\${CONFIG[GITHUB_API_URL]}\" \"\${CONFIG[GITHUB_WEB_URL]}\"" + assert_status "defaults" 0 "$STATUS" + assert_eq "trailing slashes removed" $'https://git.example.test\nhttps://git.example.test/api/v1\nhttps://api.github.com\nhttps://github.com' "$OUT" +} + +test_credentials_validation() { + local case_name content expected + while IFS='|' read -r case_name content expected; do + printf '%b' "$content" >"$WORK/e.env" + run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS +validate_credentials" + assert_status "$case_name" 1 "$STATUS" + assert_contains "$case_name message" "$ERR" "$expected" + done <<'EOF' +no Gitea token|GITHUB_USER=octo\n|GITEA_TOKEN is missing +token too short|GITEA_TOKEN=short\n|not a valid token +token with a backslash|GITEA_TOKEN=abc\\defgh12345\n|not a valid token +bad GitHub token|GITEA_TOKEN=abcdefgh12345\nGITHUB_PAT=bad token\n|GITHUB_PAT +bad GitHub user|GITEA_TOKEN=abcdefgh12345\nGITHUB_USER=-bad-\n|GITHUB_USER +EOF +} + +test_github_credentials_required_only_when_chosen() { + printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/e.env" + run_lib "" "parse_env_file \"$WORK/e.env\" CREDENTIAL_KEYS CREDENTIALS +validate_credentials +echo no-github-ok +ENV_FILE=\"$WORK/e.env\" +require_github_credentials" + assert_contains "Gitea-only .env is valid" "$OUT" "no-github-ok" + assert_status "GitHub credentials missing" 1 "$STATUS" + assert_contains "names the missing key" "$ERR" "GITHUB_PAT is missing" +} + +test_validators() { + local fn value expected + while IFS='|' read -r fn value expected; do + run_lib "" "if $fn '$value'; then echo yes; else echo no; fi" + assert_eq "$fn '$value'" "$expected" "$OUT" + done <<'EOF' +is_valid_repo_name|RepoFoundry|yes +is_valid_repo_name|my.repo_1-x|yes +is_valid_repo_name|bad name|no +is_valid_repo_name|..|no +is_valid_repo_name|x.git|no +is_valid_repo_name||no +is_valid_gitea_owner|Tirsvad|yes +is_valid_gitea_owner|-lead|no +is_valid_github_owner|octo-user|yes +is_valid_github_owner|octo_user|no +is_valid_github_owner|-octo|no +is_valid_github_owner|octo-|no +is_valid_directory|./my-project|yes +is_valid_directory|-rf|no +is_valid_directory||no +is_valid_token|abcdefgh12345|yes +is_valid_token|abc|no +is_valid_base_url|https://git.example.test/api/v1|yes +is_valid_base_url|http://git.example.test|no +is_valid_request_url|https://api.github.com/user?per_page=5|yes +is_valid_request_url|https://u:p@api.github.com/user|no +EOF +} + +test_example_files_hold_placeholders_only() { + local file line value + for file in "$REPO_ROOT/.env.example" "$REPO_ROOT/config.env.example"; do + assert_file_exists "example file" "$file" + done + while IFS= read -r line; do + value="${line#*=}" + check + if [[ -n $value ]]; then + fail ".env.example has a value for ${line%%=*}; it must be empty" + fi + done < <(grep -E '^[A-Z_]+=' "$REPO_ROOT/.env.example") + run_lib "" "parse_env_file \"$REPO_ROOT/config.env.example\" CONFIG_KEYS CONFIG +validate_config +echo parsed" + assert_contains "config.env.example is valid" "$OUT" "parsed" + run_lib "" "parse_env_file \"$REPO_ROOT/.env.example\" CREDENTIAL_KEYS CREDENTIALS +echo parsed" + assert_contains ".env.example parses" "$OUT" "parsed" +} + +test_env_is_ignored_by_git() { + check + if ! git -C "$REPO_ROOT" check-ignore -q .env; then + fail ".env is not ignored by git" + fi + check + if git -C "$REPO_ROOT" check-ignore -q .env.example; then + fail ".env.example must not be ignored" + fi +} diff --git a/tests/test-http.sh b/tests/test-http.sh new file mode 100644 index 0000000..4265663 --- /dev/null +++ b/tests/test-http.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# test-http.sh - tests for the HTTP helper, the tool check and the JSON +# helpers (MIL-001 task: tool check and HTTP helper). A stub curl stands in +# for the network. Sourced by run-tests.sh. + +# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose +test_http_request_keeps_token_off_the_command_line() { + write_curl_stub + run_lib "" "setup_temp_dir +http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN' +echo \"status=\$HTTP_STATUS\" +cleanup" + assert_status "request succeeds" 0 "$STATUS" + assert_contains "status captured" "$OUT" "status=200" + assert_file_exists "stub saw the call" "$WORK/curl.args" + assert_not_contains "token not in arguments" "$(cat "$WORK/curl.args")" "$FAKE_GITEA_TOKEN" + assert_contains "token in private config" "$(cat "$WORK/curl.config")" "Authorization: token $FAKE_GITEA_TOKEN" + assert_contains "redirects are not followed" "$(cat "$WORK/curl.args")" "--silent" + assert_not_contains "no redirect flag" "$(cat "$WORK/curl.args")" "--location" + assert_not_contains "no -L flag" "$(cat "$WORK/curl.args")" $'\n-L\n' + assert_not_contains "token not printed" "$OUT$ERR" "$FAKE_GITEA_TOKEN" +} + +test_http_request_cleans_up_its_files() { + write_curl_stub + run_lib "" "setup_temp_dir +http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN' +cleanup" + assert_eq "no temp files left" "" "$(find "$WORK/tmp" -mindepth 1 2>/dev/null)" +} + +test_http_request_bearer_scheme_and_body() { + write_curl_stub + run_lib "" "setup_temp_dir +http_request POST https://api.github.com/user/repos bearer '$FAKE_GITHUB_PAT' '{\"name\":\"x\"}' +cleanup" + assert_status "POST succeeds" 0 "$STATUS" + assert_contains "bearer header" "$(cat "$WORK/curl.config")" "Authorization: Bearer $FAKE_GITHUB_PAT" + assert_contains "content type" "$(cat "$WORK/curl.config")" "Content-Type: application/json" + assert_contains "body sent from a file" "$(cat "$WORK/curl.args")" "--data-binary" + assert_not_contains "token not in arguments" "$(cat "$WORK/curl.args")" "$FAKE_GITHUB_PAT" +} + +test_http_status_messages() { + local code expected + while IFS='|' read -r code expected; do + run_lib "" "describe_http_status $code" + assert_contains "HTTP $code" "$OUT" "$expected" + done <<'EOF' +401|authentication failed +403|scopes +404|not found +422|already exist +429|rate limited +503|server reported an error +418|unexpected HTTP status 418 +EOF +} + +test_http_network_failure() { + write_curl_stub + run_lib "" "setup_temp_dir +STUB_CURL_EXIT=7 http_request GET https://git.example.test/api/v1/user token '$FAKE_GITEA_TOKEN' || echo \"failed: \$HTTP_ERROR\" +cleanup" + assert_contains "network error reported" "$OUT" "failed: could not reach git.example.test: could not connect" + assert_not_contains "token not in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN" +} + +test_http_request_refuses_unsafe_input() { + local case_name args expected + write_curl_stub + while IFS='|' read -r case_name args expected; do + run_lib "" "setup_temp_dir +http_request $args +cleanup" + assert_status "$case_name" 1 "$STATUS" + assert_contains "$case_name message" "$ERR" "$expected" + done <<'EOF' +http URL|GET http://git.example.test/x token abcdefgh12345|invalid or non-https URL +user info URL|GET https://u:p@git.example.test/x token abcdefgh12345|invalid or non-https URL +bad method|TRACE https://git.example.test/x token abcdefgh12345|unsupported HTTP method +bad token|GET https://git.example.test/x token 'bad token'|malformed token +bad scheme|GET https://git.example.test/x basic abcdefgh12345|unknown authentication scheme +EOF + assert_file_missing "curl never called" "$WORK/curl.args" +} + +test_check_tools_stops_before_any_change() { + # An empty PATH: no git, curl or mktemp, so the check must fail first and + # the script must not create anything, not even a temporary directory. + write_fixtures + mkdir -p "$WORK/emptybin" + STATUS=0 + PATH="$WORK/emptybin" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \ + --config "$WORK/config.env" --env "$WORK/.env" "$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$? + assert_status "missing tools" 1 "$STATUS" + assert_contains "names the tools" "$(cat "$WORK/err.txt")" "required tool(s) not found: git curl mktemp" + assert_eq "nothing created" "" "$(find "$WORK/tmp" -mindepth 1 2>/dev/null)" +} + +test_missing_jq_is_only_a_warning() { + run_lib "" "command() { if [[ \$1 == -v && \$2 == jq ]]; then return 1; fi; builtin command \"\$@\"; } +check_tools +echo \"HAS_JQ=\$HAS_JQ\"" + assert_status "jq optional" 0 "$STATUS" + assert_contains "jq flag cleared" "$OUT" "HAS_JQ=0" + assert_contains "warning shown" "$ERR" "jq not found" +} + +test_json_escape() { + run_lib "" 'json_escape "say \"hi\" \\ back"; echo; json_escape $'"'"'a\nb\tc'"'"'; echo' + assert_eq "escaped" $'say \\"hi\\" \\\\ back\na\\nb\\tc' "$OUT" +} + +test_json_get_with_and_without_jq() { + local mode + printf '{"id": 42, "name": "RepoFoundry", "private": false, "other": {"name": "x"}}\n' >"$WORK/r.json" + for mode in 0 1; do + if ((mode)) && ! command -v jq >/dev/null 2>&1; then + continue + fi + run_lib "" "HAS_JQ=$mode +json_get '$WORK/r.json' id +json_get '$WORK/r.json' name +json_get '$WORK/r.json' private +json_get '$WORK/r.json' missing" + assert_eq "json_get with HAS_JQ=$mode" $'42\nRepoFoundry\nfalse' "$OUT" + done +} diff --git a/tests/test-prompts.sh b/tests/test-prompts.sh new file mode 100644 index 0000000..7762925 --- /dev/null +++ b/tests/test-prompts.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# test-prompts.sh - tests for the interactive prompts and their validation +# (MIL-001 task: prompts). Answers are piped to stdin. Sourced by +# run-tests.sh. + +# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose +test_prompt_value_asks_again_until_valid() { + run_lib $'bad name\n..\nok-name\n' \ + 'prompt_value "Repository name" "" is_valid_repo_name "use letters"; echo "[$REPLY]"' + assert_status "retries" 0 "$STATUS" + assert_eq "valid answer returned" "[ok-name]" "$OUT" + assert_contains "told why" "$ERR" "invalid Repository name: use letters" +} + +test_prompt_value_uses_the_default() { + run_lib $'\n' \ + 'prompt_value "Local directory" "./proj" is_valid_directory "x"; echo "[$REPLY]"' + assert_eq "default taken" "[./proj]" "$OUT" +} + +test_prompt_stops_when_input_ends() { + run_lib "" 'prompt_value "Repository name" "" is_valid_repo_name "x" "$WORK/.env" + run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/config.env" --env "$WORK/.env" + assert_status "Gitea-only run needs no GitHub credentials" 0 "$STATUS" + assert_contains "GitHub not used" "$OUT" "GitHub : not used" + assert_not_contains "no AGPL line" "$OUT" "AGPL" + assert_contains "GITHUB_PAT not set" "$OUT" "GITHUB_PAT not set" +} + +test_github_chosen_without_credentials_fails() { + write_fixtures + printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env" + run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env" + assert_status "missing GitHub credentials" 1 "$STATUS" + assert_contains "names the key" "$ERR" "GITHUB_PAT is missing" + assert_not_contains "no token in the error" "$OUT$ERR" "$FAKE_GITEA_TOKEN" + assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)" +} + +test_error_messages_never_contain_the_value() { + write_fixtures + printf 'GITEA_TOKEN=S3CR3T\nGITHUB_PAT=%s\n' "$FAKE_GITHUB_PAT" >"$WORK/.env" + run_cli "" --config "$WORK/config.env" --env "$WORK/.env" + assert_status "invalid token" 1 "$STATUS" + assert_contains "says what is wrong" "$ERR" "GITEA_TOKEN" + assert_not_contains "invalid value not echoed" "$OUT$ERR" "S3CR3T" + assert_not_contains "valid PAT not echoed" "$OUT$ERR" "$FAKE_GITHUB_PAT" + printf 'this line holds %s as text\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env" + run_cli "" --config "$WORK/config.env" --env "$WORK/.env" + assert_status "malformed line" 1 "$STATUS" + assert_not_contains "malformed line not echoed" "$OUT$ERR" "$FAKE_GITEA_TOKEN" +} + +test_unknown_credential_key_in_config_is_rejected() { + write_fixtures + printf 'GITEA_URL=https://git.example.test\nGITHUB_PAT=%s\n' "$FAKE_GITHUB_PAT" >"$WORK/config.env" + run_cli "" --config "$WORK/config.env" --env "$WORK/.env" + assert_status "credential in config.env" 1 "$STATUS" + assert_contains "explains" "$ERR" "keep it in the .env file only" + assert_not_contains "token not echoed" "$OUT$ERR" "$FAKE_GITHUB_PAT" +} + +test_redaction() { + run_lib "" "SECRET_VALUES=('$FAKE_GITEA_TOKEN') +say 'token is $FAKE_GITEA_TOKEN here' +warn 'also $FAKE_GITEA_TOKEN' +die 'and $FAKE_GITEA_TOKEN'" + assert_status "die exits 1" 1 "$STATUS" + assert_eq "say redacts" "token is [redacted] here" "$OUT" + assert_not_contains "stderr redacted" "$ERR" "$FAKE_GITEA_TOKEN" + assert_contains "die message" "$ERR" "error: and [redacted]" +} + +test_usage_errors() { + run_cli "" --bogus + assert_status "unknown option" 2 "$STATUS" + assert_contains "says so" "$ERR" "unknown option: --bogus" + run_cli "" --config + assert_status "option without value" 2 "$STATUS" + run_cli "" --help + assert_status "help" 0 "$STATUS" + assert_contains "help shows usage" "$OUT" "Usage" + assert_contains "help shows exit codes" "$OUT" "Exit codes" + run_cli "" --version + assert_status "version" 0 "$STATUS" + assert_contains "version output" "$OUT" "RepoFoundry 0.1.0" +} + +test_warns_when_env_is_not_ignored_by_git() { + write_fixtures + git init -q "$WORK/repo" + cp "$WORK/.env" "$WORK/repo/.env" + cp "$WORK/config.env" "$WORK/repo/config.env" + run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/repo/config.env" --env "$WORK/repo/.env" + assert_status "still runs" 0 "$STATUS" + assert_contains "warns" "$ERR" "is not ignored by git" + assert_not_contains "no token in the warning" "$ERR" "$FAKE_GITEA_TOKEN" + find "$WORK/repo" -type f -delete + find "$WORK/repo" -depth -type d -exec rmdir {} + +} + +test_script_uses_no_unsafe_constructs() { + local code + code="$(grep -vE '^[[:space:]]*#' "$SCRIPT")" + assert_not_contains "no rm -rf" "$code" "rm -rf" + assert_not_contains "no rm -r" "$code" "rm -r " + assert_not_contains "no eval" "$code" "eval " + assert_not_contains "no source" "$code" "source " + assert_not_contains "no dot-source" "$code" $'\n. ' + assert_not_contains "no set -x" "$code" "set -x" + assert_not_contains "no fixed /tmp file" "$code" "/tmp/file" +}