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
+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