Ask for missing credentials and create the project's own .env

.env becomes optional. A credential it does not provide (an absent file, an
absent or empty key) is asked, without echo: GITEA_TOKEN at the start,
GITHUB_PAT and GITHUB_USER once GitHub is chosen. An invalid value is asked
again and never shown; when input ends the run stops before any request.
Asked tokens are registered for redaction at once.

After the local project exists the script asks (default no) whether to
create a .env in it. On a yes it holds only the needed keys, is created
private (mode 600) from the start, is excluded from git through
.git/info/exclude (no tracked file changes), is never replaced without a
second yes and is never written when git tracks it. The summary names the
keys, never the values.

New library files credentials.sh and envfile.sh; README, .env.example and
the security decisions updated; tests cover every case.

Task: MIL-005#1
Task: MIL-005#2
Task: MIL-005#3
Task: MIL-005#4
Task: MIL-005#5
Closes #35
Closes #36
Closes #37
Closes #38
Closes #39

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 13:29:01 +08:00
co-authored by Claude Sonnet 5.5
parent 3804ef7556
commit 05a7159c18
13 changed files with 544 additions and 50 deletions
+15 -6
View File
@@ -11,7 +11,9 @@
# repository, remotes (no credential in any address), the framework as a
# submodule, the framework's skills and git hooks (and the plan gate if
# chosen) and its templates. Choosing GitHub also applies the AGPL-3.0
# license to the Gitea repository. No commit is made in the new project.
# license to the Gitea repository. After a yes (default no) it also writes
# the new project's own .env with the credentials the project needs. No
# commit is made in the new project.
#
# Dry run by default
# Without --apply the script only reads from GitHub and Gitea (GET
@@ -27,7 +29,8 @@
# Options
# --apply create the repositories and the mirror (after a final yes)
# --config FILE service addresses (default: config.env in the project root)
# --env FILE credentials (default: .env in the project root)
# --env FILE credentials (default: .env in the project root); optional:
# a credential it does not provide is asked, not echoed
# -h, --help show this help
# --version show the version
#
@@ -36,7 +39,8 @@
# the optional GITEA_SSH_PORT (default 10022), MIRROR_INTERVAL
# (default 10m0s) and FRAMEWORK_REPO (default
# TirSystem/SQA-QC-Framework, the submodule's OWNER/NAME)
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN
# .env GITHUB_PAT, GITHUB_USER, GITEA_TOKEN (all optional, each
# asked when missing)
#
# Environment
# REPOFOUNDRY_NAME project name used in messages (default: RepoFoundry)
@@ -65,8 +69,8 @@
# This file is the entry point. The work is split by responsibility into
# the files in lib/ next to it (one job per file, see the first lines of
# each file): constants, output, temp, util, validate, config, tools, json,
# http, api, prompts, project, hosts, preflight, steps, plan, repositories,
# mirror, git, localproject, framework, apply and cli. The files are loaded
# http, api, prompts, credentials, project, hosts, preflight, steps, plan,
# repositories, mirror, git, localproject, framework, envfile, apply and cli. The files are loaded
# from this directory only.
#
# Exit codes
@@ -121,6 +125,8 @@ source "$SCRIPT_DIR/lib/http.sh"
source "$SCRIPT_DIR/lib/api.sh"
# shellcheck source=lib/prompts.sh
source "$SCRIPT_DIR/lib/prompts.sh"
# shellcheck source=lib/credentials.sh
source "$SCRIPT_DIR/lib/credentials.sh"
# shellcheck source=lib/project.sh
source "$SCRIPT_DIR/lib/project.sh"
# shellcheck source=lib/hosts.sh
@@ -141,6 +147,8 @@ source "$SCRIPT_DIR/lib/git.sh"
source "$SCRIPT_DIR/lib/localproject.sh"
# shellcheck source=lib/framework.sh
source "$SCRIPT_DIR/lib/framework.sh"
# shellcheck source=lib/envfile.sh
source "$SCRIPT_DIR/lib/envfile.sh"
# shellcheck source=lib/apply.sh
source "$SCRIPT_DIR/lib/apply.sh"
# shellcheck source=lib/cli.sh
@@ -166,9 +174,10 @@ main() {
check_tools
setup_temp_dir
load_configuration
collect_credentials GITEA_TOKEN
collect_project_details
if ((PROJECT[has_github])); then
require_github_credentials
collect_credentials GITHUB_PAT GITHUB_USER
fi
init_steps
print_summary
+1
View File
@@ -19,6 +19,7 @@ create_all() {
add_framework
install_framework
copy_templates
create_env_file
}
# confirm_framework_access: the framework comes over SSH. Without SSH the
+16 -20
View File
@@ -4,7 +4,7 @@
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, check_preset, check_preset_choice, validate_project_presets, validate_credentials, require_github_credentials, warn_if_env_unsafe, load_configuration
# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, check_preset, check_preset_choice, validate_project_presets, validate_credentials, warn_if_env_unsafe, load_configuration
# unquote_value RAW: strip matching quotes (or a trailing " # comment" on an
# unquoted value) and return the value in REPLY. Fails on unbalanced quotes.
@@ -144,20 +144,23 @@ validate_project_presets() {
check_preset_choice ENABLE_PLAN_GATE yes no
}
# validate_credentials: check the credentials that .env provides. A credential
# that is not provided is not an error: it is asked later (collect_credentials).
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[GITEA_TOKEN]:-} ]]; then
SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}")
fi
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[GITEA_TOKEN]:-} ]] &&
! is_valid_token "${CREDENTIALS[GITEA_TOKEN]}"; then
die "GITEA_TOKEN in $ENV_FILE is not a valid token ($HINT_TOKEN)"
fi
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 _ . ~ + / = -)"
die "GITHUB_PAT in $ENV_FILE is not a valid token ($HINT_TOKEN)"
fi
if [[ -n ${CREDENTIALS[GITHUB_USER]:-} ]] &&
! is_valid_github_owner "${CREDENTIALS[GITHUB_USER]}"; then
@@ -165,16 +168,6 @@ validate_credentials() {
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
@@ -200,7 +193,10 @@ warn_if_env_unsafe() {
load_configuration() {
parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG
validate_config
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
# .env is optional: a credential it does not provide is asked.
if [[ -e $ENV_FILE ]]; then
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
warn_if_env_unsafe "$ENV_FILE"
fi
validate_credentials
warn_if_env_unsafe "$ENV_FILE"
}
+3 -1
View File
@@ -26,8 +26,10 @@ readonly HINT_DESCRIPTION="at most $MAX_DESCRIPTION_LENGTH characters and no con
readonly HINT_GITEA_OWNER="use letters, digits, '.', '_' or '-' (at most 39)"
readonly HINT_GITHUB_OWNER="use letters, digits or '-' (at most 39)"
readonly HINT_DIRECTORY="must not be empty, start with '-' or contain control characters"
readonly HINT_TOKEN="8 to 255 letters, digits or _ . ~ + / = -"
readonly ENV_FILE_NAME=".env"
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror"
"Local project" "Framework" "Skills and hooks" "Templates")
"Local project" "Framework" "Skills and hooks" "Templates" "Project .env")
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
GITEA_SSH_PORT MIRROR_INTERVAL FRAMEWORK_REPO
+42
View File
@@ -0,0 +1,42 @@
# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# credentials.sh - Asking for a credential that .env does not provide.
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: credential_label, collect_credentials
# credential_label KEY: the name of a credential as the Maintainer sees it.
credential_label() {
case "$1" in
GITEA_TOKEN) printf 'Gitea access token' ;;
GITHUB_PAT) printf 'GitHub personal access token' ;;
GITHUB_USER) printf 'GitHub account name (the account the token belongs to)' ;;
*) printf '%s' "$1" ;;
esac
}
# collect_credentials KEY...: ask for each credential that is not already
# provided. A token is read without echo and registered as a secret at once,
# so no later message can show it; the GitHub account name is not secret and
# is read like any other answer. An empty value in .env counts as not provided.
collect_credentials() {
local key
for key in "$@"; do
if [[ -n ${CREDENTIALS[$key]:-} ]]; then
continue
fi
case "$key" in
GITHUB_USER)
prompt_value "$(credential_label "$key")" "" is_valid_github_owner \
"use letters, digits or '-' (at most 39)"
;;
*)
prompt_secret "$(credential_label "$key")" is_valid_token "$HINT_TOKEN"
SECRET_VALUES+=("$REPLY")
;;
esac
CREDENTIALS[$key]="$REPLY"
REPLY=""
done
}
+97
View File
@@ -0,0 +1,97 @@
# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# envfile.sh - The .env file of the new project: the one place a credential is written.
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: env_file_keys, env_file_key_list, exclude_env_file, write_env_file, create_env_file
# env_file_keys: the credentials the new project needs, one per line: the
# Gitea token, and the GitHub token and account name when GitHub was chosen.
env_file_keys() {
printf '%s\n' GITEA_TOKEN
if ((PROJECT[has_github])); then
printf '%s\n' GITHUB_PAT GITHUB_USER
fi
}
# env_file_key_list: the same keys on one line, for messages.
env_file_key_list() {
local keys
keys="$(env_file_keys | tr '\n' ' ')"
printf '%s' "${keys% }"
}
# exclude_env_file DIR: make git ignore .env in DIR without touching a tracked
# file: the entry goes into .git/info/exclude, which is never committed. It
# does nothing when .env is already ignored.
exclude_env_file() {
local dir="$1" gitdir exclude
if git_project "$dir" check-ignore -q -- "$ENV_FILE_NAME"; then
return 0
fi
gitdir="$(git_project "$dir" rev-parse --absolute-git-dir)"
exclude="$gitdir/info/exclude"
mkdir -p -- "$gitdir/info"
# Start on a fresh line when the file does not end with one.
if [[ -s $exclude && -n "$(tail -c 1 -- "$exclude")" ]]; then
printf '\n' >>"$exclude"
fi
printf '%s\n' "# RepoFoundry: the credentials file of this project" "$ENV_FILE_NAME" >>"$exclude"
git_project "$dir" check-ignore -q -- "$ENV_FILE_NAME" ||
die "could not make git ignore $ENV_FILE_NAME in $dir; nothing was written to it"
}
# write_env_file DIR IS_REPLACE: write the credentials to DIR/.env. The file is
# created private (mode 600) from the start, never readable by others, even
# for a moment: it is written under umask 077 as a temporary file next to the
# target and moved into place. An existing file is only replaced when
# IS_REPLACE is 1, and a file that appears in the meantime is never replaced.
write_env_file() {
local dir="$1" is_replace="$2" target tmp key
target="$dir/$ENV_FILE_NAME"
tmp="$(umask 077 && mktemp "$dir/$ENV_FILE_NAME.XXXXXX")"
TEMP_FILES+=("$tmp")
{
while IFS= read -r key; do
printf '%s=%s\n' "$key" "${CREDENTIALS[$key]}"
done < <(env_file_keys)
} >"$tmp"
if ((is_replace)); then
mv -f -- "$tmp" "$target"
else
mv -n -- "$tmp" "$target"
if [[ -e $tmp ]]; then
die "$target appeared while it was being written; it was not replaced"
fi
fi
}
# create_env_file: the last step. Only after a yes (default no) is the .env
# written, and an existing one is only replaced after another yes. Nothing
# printed names a value, only the keys.
create_env_file() {
local label="Project .env" dir="${PROJECT[directory]}" keys is_replace=0
keys="$(env_file_key_list)"
begin_step "$label"
prompt_yes_no "Create a $ENV_FILE_NAME file in the project with the credentials it needs ($keys); only you can read it and git ignores it" n
if ! ((REPLY)); then
finish_step "$label" "skipped" "(you declined)"
return 0
fi
if git_project "$dir" ls-files --error-unmatch -- "$ENV_FILE_NAME" >/dev/null 2>&1; then
finish_step "$label" "skipped" "($ENV_FILE_NAME is tracked by git; it was not written)"
return 0
fi
if [[ -e $dir/$ENV_FILE_NAME || -L $dir/$ENV_FILE_NAME ]]; then
prompt_yes_no "$ENV_FILE_NAME already exists in the project. Replace it" n
if ! ((REPLY)); then
finish_step "$label" "kept" "(the existing $ENV_FILE_NAME was left as it was)"
return 0
fi
is_replace=1
fi
exclude_env_file "$dir"
write_env_file "$dir" "$is_replace"
finish_step "$label" "created" "($keys; only you can read it, git ignores it)"
}
+1
View File
@@ -54,4 +54,5 @@ print_plan() {
else
say "$(printf ' %-18s: %s' "Framework" "NOT possible without SSH to Gitea; you will be asked whether to go on without it")"
fi
say "$(printf ' %-18s: %s' "Project .env" "you are asked whether to create it ($(env_file_key_list))")"
}
+22 -1
View File
@@ -4,7 +4,7 @@
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: prompt_value, prompt_choice, prompt_yes_no
# Provides: prompt_value, prompt_secret, prompt_choice, prompt_yes_no
# prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the
# answer; the accepted answer is returned in REPLY.
@@ -27,6 +27,27 @@ prompt_value() {
done
}
# prompt_secret LABEL VALIDATOR HINT: like prompt_value for a secret. What is
# typed is not shown (read -s) and a refused answer is never repeated in the
# message. An empty answer is refused; there is no default.
prompt_secret() {
local label="$1" validator="$2" hint="$3" answer
while true; do
printf '%s (input is hidden): ' "$label" >&2
IFS= read -rs answer || {
printf '\n' >&2
die "no input available for '$label'"
}
printf '\n' >&2 # the newline that hidden input did not echo
answer="$(trim "$answer")"
if [[ -n $answer ]] && "$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