mirror of
https://github.com/github/spec-kit.git
synced 2026-03-17 02:43:08 +00:00
* fix(scripts): harden bash scripts with escape, compat, and cleanup fixes - common.sh: complete RFC 8259 JSON escape (\b, \f, strip control chars) - common.sh: distinguish python3 success-empty vs failure in resolve_template - check-prerequisites.sh: escape doc names through json_escape in fallback path - create-new-feature.sh: remove duplicate json_escape (already in common.sh) - create-new-feature.sh: warn on stderr when spec template is not found - update-agent-context.sh: move nested function to top-level for bash 3.2 compat * fix(scripts): explicit resolve_template return code and best-effort agent updates - common.sh: resolve_template now returns 1 when no template is found, making the "not found" case explicit instead of relying on empty stdout - setup-plan.sh, create-new-feature.sh: add || true to resolve_template calls so set -e does not abort on missing templates (non-fatal) - update-agent-context.sh: accumulate errors in update_all_existing_agents instead of silently discarding them — all agents are attempted and the composite result is returned, matching the PowerShell equivalent behavior * style(scripts): add clarifying comment in resolve_template preset branch * fix(scripts): wrap python3 call in if-condition to prevent set -e abort Move the python3 command substitution in resolve_template into an if-condition so that a non-zero exit (e.g. invalid .registry JSON) does not abort the function under set -e. The fallback directory scan now executes as intended regardless of caller errexit settings. * fix(scripts): track agent file existence before update and avoid top-level globals - _update_if_new now records the path and sets _found_agent before calling update_agent_file, so that failures do not cause duplicate attempts on aliased paths (AMP/KIRO/BOB -> AGENTS_FILE) or false "no agent files found" fallback triggers - Remove top-level initialisation of _updated_paths and _found_agent; they are now created exclusively inside update_all_existing_agents, keeping the script side-effect free when sourced
264 lines
9.3 KiB
Bash
264 lines
9.3 KiB
Bash
#!/usr/bin/env bash
|
||
# Common functions and variables for all scripts
|
||
|
||
# Get repository root, with fallback for non-git repositories
|
||
get_repo_root() {
|
||
if git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||
git rev-parse --show-toplevel
|
||
else
|
||
# Fall back to script location for non-git repos
|
||
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
(cd "$script_dir/../../.." && pwd)
|
||
fi
|
||
}
|
||
|
||
# Get current branch, with fallback for non-git repositories
|
||
get_current_branch() {
|
||
# First check if SPECIFY_FEATURE environment variable is set
|
||
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
||
echo "$SPECIFY_FEATURE"
|
||
return
|
||
fi
|
||
|
||
# Then check git if available
|
||
if git rev-parse --abbrev-ref HEAD >/dev/null 2>&1; then
|
||
git rev-parse --abbrev-ref HEAD
|
||
return
|
||
fi
|
||
|
||
# For non-git repos, try to find the latest feature directory
|
||
local repo_root=$(get_repo_root)
|
||
local specs_dir="$repo_root/specs"
|
||
|
||
if [[ -d "$specs_dir" ]]; then
|
||
local latest_feature=""
|
||
local highest=0
|
||
|
||
for dir in "$specs_dir"/*; do
|
||
if [[ -d "$dir" ]]; then
|
||
local dirname=$(basename "$dir")
|
||
if [[ "$dirname" =~ ^([0-9]{3})- ]]; then
|
||
local number=${BASH_REMATCH[1]}
|
||
number=$((10#$number))
|
||
if [[ "$number" -gt "$highest" ]]; then
|
||
highest=$number
|
||
latest_feature=$dirname
|
||
fi
|
||
fi
|
||
fi
|
||
done
|
||
|
||
if [[ -n "$latest_feature" ]]; then
|
||
echo "$latest_feature"
|
||
return
|
||
fi
|
||
fi
|
||
|
||
echo "main" # Final fallback
|
||
}
|
||
|
||
# Check if we have git available
|
||
has_git() {
|
||
git rev-parse --show-toplevel >/dev/null 2>&1
|
||
}
|
||
|
||
check_feature_branch() {
|
||
local branch="$1"
|
||
local has_git_repo="$2"
|
||
|
||
# For non-git repos, we can't enforce branch naming but still provide output
|
||
if [[ "$has_git_repo" != "true" ]]; then
|
||
echo "[specify] Warning: Git repository not detected; skipped branch validation" >&2
|
||
return 0
|
||
fi
|
||
|
||
if [[ ! "$branch" =~ ^[0-9]{3}- ]]; then
|
||
echo "ERROR: Not on a feature branch. Current branch: $branch" >&2
|
||
echo "Feature branches should be named like: 001-feature-name" >&2
|
||
return 1
|
||
fi
|
||
|
||
return 0
|
||
}
|
||
|
||
get_feature_dir() { echo "$1/specs/$2"; }
|
||
|
||
# Find feature directory by numeric prefix instead of exact branch match
|
||
# This allows multiple branches to work on the same spec (e.g., 004-fix-bug, 004-add-feature)
|
||
find_feature_dir_by_prefix() {
|
||
local repo_root="$1"
|
||
local branch_name="$2"
|
||
local specs_dir="$repo_root/specs"
|
||
|
||
# Extract numeric prefix from branch (e.g., "004" from "004-whatever")
|
||
if [[ ! "$branch_name" =~ ^([0-9]{3})- ]]; then
|
||
# If branch doesn't have numeric prefix, fall back to exact match
|
||
echo "$specs_dir/$branch_name"
|
||
return
|
||
fi
|
||
|
||
local prefix="${BASH_REMATCH[1]}"
|
||
|
||
# Search for directories in specs/ that start with this prefix
|
||
local matches=()
|
||
if [[ -d "$specs_dir" ]]; then
|
||
for dir in "$specs_dir"/"$prefix"-*; do
|
||
if [[ -d "$dir" ]]; then
|
||
matches+=("$(basename "$dir")")
|
||
fi
|
||
done
|
||
fi
|
||
|
||
# Handle results
|
||
if [[ ${#matches[@]} -eq 0 ]]; then
|
||
# No match found - return the branch name path (will fail later with clear error)
|
||
echo "$specs_dir/$branch_name"
|
||
elif [[ ${#matches[@]} -eq 1 ]]; then
|
||
# Exactly one match - perfect!
|
||
echo "$specs_dir/${matches[0]}"
|
||
else
|
||
# Multiple matches - this shouldn't happen with proper naming convention
|
||
echo "ERROR: Multiple spec directories found with prefix '$prefix': ${matches[*]}" >&2
|
||
echo "Please ensure only one spec directory exists per numeric prefix." >&2
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
get_feature_paths() {
|
||
local repo_root=$(get_repo_root)
|
||
local current_branch=$(get_current_branch)
|
||
local has_git_repo="false"
|
||
|
||
if has_git; then
|
||
has_git_repo="true"
|
||
fi
|
||
|
||
# Use prefix-based lookup to support multiple branches per spec
|
||
local feature_dir
|
||
if ! feature_dir=$(find_feature_dir_by_prefix "$repo_root" "$current_branch"); then
|
||
echo "ERROR: Failed to resolve feature directory" >&2
|
||
return 1
|
||
fi
|
||
|
||
# Use printf '%q' to safely quote values, preventing shell injection
|
||
# via crafted branch names or paths containing special characters
|
||
printf 'REPO_ROOT=%q\n' "$repo_root"
|
||
printf 'CURRENT_BRANCH=%q\n' "$current_branch"
|
||
printf 'HAS_GIT=%q\n' "$has_git_repo"
|
||
printf 'FEATURE_DIR=%q\n' "$feature_dir"
|
||
printf 'FEATURE_SPEC=%q\n' "$feature_dir/spec.md"
|
||
printf 'IMPL_PLAN=%q\n' "$feature_dir/plan.md"
|
||
printf 'TASKS=%q\n' "$feature_dir/tasks.md"
|
||
printf 'RESEARCH=%q\n' "$feature_dir/research.md"
|
||
printf 'DATA_MODEL=%q\n' "$feature_dir/data-model.md"
|
||
printf 'QUICKSTART=%q\n' "$feature_dir/quickstart.md"
|
||
printf 'CONTRACTS_DIR=%q\n' "$feature_dir/contracts"
|
||
}
|
||
|
||
# Check if jq is available for safe JSON construction
|
||
has_jq() {
|
||
command -v jq >/dev/null 2>&1
|
||
}
|
||
|
||
# Escape a string for safe embedding in a JSON value (fallback when jq is unavailable).
|
||
# Handles backslash, double-quote, and JSON-required control character escapes (RFC 8259).
|
||
json_escape() {
|
||
local s="$1"
|
||
s="${s//\\/\\\\}"
|
||
s="${s//\"/\\\"}"
|
||
s="${s//$'\n'/\\n}"
|
||
s="${s//$'\t'/\\t}"
|
||
s="${s//$'\r'/\\r}"
|
||
s="${s//$'\b'/\\b}"
|
||
s="${s//$'\f'/\\f}"
|
||
# Strip remaining control characters (U+0000–U+001F) not individually escaped above
|
||
s=$(printf '%s' "$s" | tr -d '\000-\007\013\016-\037')
|
||
printf '%s' "$s"
|
||
}
|
||
|
||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||
|
||
# Resolve a template name to a file path using the priority stack:
|
||
# 1. .specify/templates/overrides/
|
||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||
# 3. .specify/extensions/<ext-id>/templates/
|
||
# 4. .specify/templates/ (core)
|
||
resolve_template() {
|
||
local template_name="$1"
|
||
local repo_root="$2"
|
||
local base="$repo_root/.specify/templates"
|
||
|
||
# Priority 1: Project overrides
|
||
local override="$base/overrides/${template_name}.md"
|
||
[ -f "$override" ] && echo "$override" && return 0
|
||
|
||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||
local presets_dir="$repo_root/.specify/presets"
|
||
if [ -d "$presets_dir" ]; then
|
||
local registry_file="$presets_dir/.registry"
|
||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||
# The python3 call is wrapped in an if-condition so that set -e does not
|
||
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||
local sorted_presets=""
|
||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||
import json, sys, os
|
||
try:
|
||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||
data = json.load(f)
|
||
presets = data.get('presets', {})
|
||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10)):
|
||
print(pid)
|
||
except Exception:
|
||
sys.exit(1)
|
||
" 2>/dev/null); then
|
||
if [ -n "$sorted_presets" ]; then
|
||
# python3 succeeded and returned preset IDs — search in priority order
|
||
while IFS= read -r preset_id; do
|
||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||
done <<< "$sorted_presets"
|
||
fi
|
||
# python3 succeeded but registry has no presets — nothing to search
|
||
else
|
||
# python3 failed (missing, or registry parse error) — fall back to unordered directory scan
|
||
for preset in "$presets_dir"/*/; do
|
||
[ -d "$preset" ] || continue
|
||
local candidate="$preset/templates/${template_name}.md"
|
||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||
done
|
||
fi
|
||
else
|
||
# Fallback: alphabetical directory order (no python3 available)
|
||
for preset in "$presets_dir"/*/; do
|
||
[ -d "$preset" ] || continue
|
||
local candidate="$preset/templates/${template_name}.md"
|
||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||
done
|
||
fi
|
||
fi
|
||
|
||
# Priority 3: Extension-provided templates
|
||
local ext_dir="$repo_root/.specify/extensions"
|
||
if [ -d "$ext_dir" ]; then
|
||
for ext in "$ext_dir"/*/; do
|
||
[ -d "$ext" ] || continue
|
||
# Skip hidden directories (e.g. .backup, .cache)
|
||
case "$(basename "$ext")" in .*) continue;; esac
|
||
local candidate="$ext/templates/${template_name}.md"
|
||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||
done
|
||
fi
|
||
|
||
# Priority 4: Core templates
|
||
local core="$base/${template_name}.md"
|
||
[ -f "$core" ] && echo "$core" && return 0
|
||
|
||
# Template not found in any location.
|
||
# Return 1 so callers can distinguish "not found" from "found".
|
||
# Callers running under set -e should use: TEMPLATE=$(resolve_template ...) || true
|
||
return 1
|
||
}
|
||
|