5 Commits
Author SHA1 Message Date
Tirsvad 4b6e5b6c67 Merge pull request 'MIL-001 Foundation: config parsing, HTTP helper, prompts and tests' (#22) from mil-001-foundation into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 3s
Reviewed-on: #22
2026-10-05 08:00:40 +02:00
TirsvadandClaude Sonnet 5.5 102dd2473d Add the RepoFoundry foundation: config parsing, HTTP helper, prompts, tests
create-project.sh validates config.env and .env (parsed, never sourced),
checks the required tools, asks for the project details and prints a
summary. It makes no network call and no change on disk yet.

- config.env.example and .env.example hold placeholders only
- tokens go through a private curl config file, never the command line
- tests run in private directories with a stub curl; shellcheck and shfmt
  are part of tests/run-tests.sh

Task: MIL-001#1
Task: MIL-001#2
Task: MIL-001#3
Task: MIL-001#4
Task: MIL-001#5
Task: MIL-001#6
Closes #3
Closes #4
Closes #5
Closes #6
Closes #7
Closes #8

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 13:59:08 +08:00
Tirsvad 5ac230df7e Merge pull request 'Link synced milestones in the project plan' (#21) from planning into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 4s
Reviewed-on: #21
2026-10-05 07:50:59 +02:00
Tirsvad 7508db7bea Merge pull request 'Plan RepoFoundry: baseline, UC-001 artifacts and reviews' (#2) from planning into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 3s
Reviewed-on: #2
2026-10-05 07:47:30 +02:00
Tirsvad b7b1a23dc1 Merge pull request 'Plan RepoFoundry: business case, stakeholders, plan, milestones, UC-001' (#1) from planning into main
TirSystem/github-action: Sync GitHub mirror metadata / sync-metadata (push) Failing after 6s
Reviewed-on: #1
2026-10-05 06:34:57 +02:00
12 changed files with 1510 additions and 0 deletions
+9
View File
@@ -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
+22
View File
@@ -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=
+10
View File
@@ -174,3 +174,13 @@ cython_debug/
# PyPI configuration file
.pypirc
# RepoFoundry: credentials and temporary files
.env
.env.*
!.env.example
*.tmp
*.swp
*~
tmp/
repofoundry.*/
+2
View File
@@ -0,0 +1,2 @@
# shellcheck configuration for the RepoFoundry scripts
shell=bash
+23
View File
@@ -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
+689
View File
@@ -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 <<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))
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
+162
View File
@@ -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" <<EOF
# test configuration
GITHUB_API_URL=https://api.github.com
GITHUB_WEB_URL=https://github.com
GITEA_URL=https://git.example.test/
GITEA_API_URL=https://git.example.test/api/v1
EOF
cat >"$WORK/.env" <<EOF
GITHUB_PAT=$FAKE_GITHUB_PAT
GITHUB_USER=octo-user
GITEA_TOKEN=$FAKE_GITEA_TOKEN
EOF
}
# write_stub NAME BODY: install an executable stub tool in the work bin.
write_stub() {
printf '#!/usr/bin/env bash\n%s\n' "$2" >"$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")"
}
+70
View File
@@ -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
+179
View File
@@ -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
}
+130
View File
@@ -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" </dev/null \
>"$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
}
+86
View File
@@ -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" </dev/null'
assert_status "end of input" 1 "$STATUS"
assert_contains "message" "$ERR" "no input available for 'Repository name'"
}
test_prompt_choice() {
run_lib $'PUBLIC\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
assert_eq "case-insensitive choice" "[public]" "$OUT"
run_lib $'\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
assert_eq "default choice" "[private]" "$OUT"
run_lib $'secret\nprivate\n' 'prompt_choice "Visibility" private private public; echo "[$REPLY]"'
assert_eq "invalid then valid" "[private]" "$OUT"
assert_contains "told the options" "$ERR" "choose one of private public"
}
test_prompt_yes_no() {
local answer expected
while IFS='|' read -r answer expected; do
run_lib "$answer"$'\n' 'prompt_yes_no "Use GitHub" y; echo "[$REPLY]"'
assert_eq "answer '$answer'" "[$expected]" "$OUT"
done <<'EOF'
|1
y|1
YES|1
n|0
No|0
EOF
run_lib $'maybe\nn\n' 'prompt_yes_no "Use GitHub" y; echo "[$REPLY]"'
assert_eq "invalid then valid" "[0]" "$OUT"
assert_contains "told what to answer" "$ERR" "answer y or n"
}
test_collect_details_with_github() {
run_lib $'my-app\nA test app\npublic\nTirSystem\ny\nmy-org\n\ny\n' \
'collect_project_details
for k in name description visibility gitea_owner use_github github_owner directory plan_gate; do
printf "%s=%s\n" "$k" "${PROJECT[$k]}"
done'
assert_status "details collected" 0 "$STATUS"
assert_eq "details" $'name=my-app\ndescription=A test app\nvisibility=public\ngitea_owner=TirSystem\nuse_github=1\ngithub_owner=my-org\ndirectory=./my-app\nplan_gate=1' "$OUT"
}
test_collect_details_without_github() {
run_lib $'my-app\n\n\nTirSystem\nn\n\nn\n' \
'collect_project_details
printf "%s|%s|%s|%s\n" "${PROJECT[visibility]}" "${PROJECT[use_github]}" "[${PROJECT[github_owner]}]" "${PROJECT[plan_gate]}"'
assert_status "GitHub skipped" 0 "$STATUS"
assert_eq "defaults and no GitHub owner" "private|0|[]|0" "$OUT"
assert_not_contains "no GitHub owner prompt" "$ERR" "GitHub owner"
}
test_github_user_is_a_default_not_the_owner() {
# GITHUB_USER only pre-fills the prompt; the Maintainer can pick an
# organization instead.
run_lib $'my-app\n\n\nTirSystem\ny\n\n\nn\n' \
'CREDENTIALS[GITHUB_USER]=octo-user
collect_project_details
echo "${PROJECT[github_owner]}"'
assert_eq "default is the account" "octo-user" "$OUT"
run_lib $'my-app\n\n\nTirSystem\ny\nacme-org\n\nn\n' \
'CREDENTIALS[GITHUB_USER]=octo-user
collect_project_details
echo "${PROJECT[github_owner]}"'
assert_eq "organization chosen" "acme-org" "$OUT"
}
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env bash
# test-security.sh - end-to-end tests: no token in any output, no network
# call, no change on disk, clean temporary files, safe failure paths
# (MIL-001 Go/No-Go criteria 2 to 4). Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
# listing: all files under the work directory except the test's own captures.
listing() {
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
! -name 'curl.*' | sort
}
test_full_run_with_github() {
write_fixtures
write_curl_stub
local before after
before="$(listing)"
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
after="$(listing)"
assert_status "full run" 0 "$STATUS"
assert_contains "summary" "$OUT" "nothing has been created yet"
assert_contains "Gitea link derived from config" "$OUT" "https://git.example.test/TirSystem/my-app"
assert_contains "GitHub link uses the chosen organization" "$OUT" "https://github.com/acme-org/my-app"
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
assert_contains "credential state" "$OUT" "GITEA_TOKEN set, GITHUB_PAT set"
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
assert_file_missing "no network call" "$WORK/curl.args"
assert_eq "no file created or changed" "$before" "$after"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_full_run_without_github() {
write_fixtures
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$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"
}