mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* feat(init)!: make git extension opt-in and remove --no-git at v0.10.0 - Remove --no-git parameter from specify init command - Remove git extension auto-installation from init flow - Git repository initialization (git init) still runs when git is available - Remove --no-git from all test invocations across the test suite - Update docs to reflect opt-in git extension behavior - Replace TestGitExtensionAutoInstall with TestGitExtensionOptIn tests BREAKING CHANGE: specify init no longer auto-installs the git extension. Use `specify extension add git` to install it explicitly. The --no-git flag has been removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove git operations from core scripts Git functionality is now entirely managed by the git extension. Core scripts only handle directory-based feature creation and numbering. - Remove has_git(), check_feature_branch(), git branch creation from core - Simplify number detection to use only spec directory scanning - Remove HAS_GIT output from get_feature_paths() - Remove git remote fetching and branch querying - Keep BRANCH_NAME output key for backward compatibility Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove all git operations from core - Remove is_git_repo() and init_git_repo() dead code from _utils.py - Remove --branch-numbering from init command - Remove git from 'specify check' (now extension-only) - Update docs: git is optional prerequisite, check command description - Fix tests to reflect no-git-in-core reality (fallback to main) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove directory scanning and branch fallback from core Core scripts now resolve feature context exclusively from: 1. SPECIFY_FEATURE env var (set by git extension) 2. .specify/feature.json (persisted by specify command) Removed find_feature_dir_by_prefix() and directory scanning heuristics — these are the git extension's responsibility. Scripts error clearly when no feature context is available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: introduce feature_numbering, deprecate branch_numbering in init-options - specify command template now reads feature_numbering (preferred) with fallback to branch_numbering (deprecated) from init-options.json - Git extension reads git-config.yml > feature_numbering > branch_numbering - init now writes feature_numbering: sequential to init-options.json - Deprecation warning emitted when branch_numbering is used as fallback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove trailing whitespace in common.ps1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): persist SPECIFY_FEATURE_DIRECTORY env var to feature.json When SPECIFY_FEATURE_DIRECTORY is set, get_feature_paths() now writes the value to .specify/feature.json so future sessions without the env var can still resolve the feature directory. The write is idempotent — it skips when the file already contains the same value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address review feedback — error messages and docs - Update error messages in common.sh and common.ps1 to reference SPECIFY_FEATURE_DIRECTORY instead of SPECIFY_FEATURE (which no longer resolves feature directories) - Fix get_current_branch comment (returns empty string, not error) - Update upgrade.md to reference SPECIFY_FEATURE_DIRECTORY with correct example paths - Update local-development.md troubleshooting: replace stale 'Git step skipped' row with actionable git extension guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(scripts): harden feature.json persistence - Use json_escape in printf fallback when jq is unavailable (common.sh) - Replace utf8NoBOM encoding with UTF8Encoding($false) for PowerShell 5.1 compatibility (common.ps1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(scripts): remove dead feature_json_matches_feature_dir functions These guards are no longer needed since the branch-name validation they protected against has been removed from check-prerequisites. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(git-ext): rename create-new-feature to create-new-feature-branch The git extension's script only creates the git branch — rename it to reflect that responsibility. The core create-new-feature.sh/.ps1 handles feature directory creation and feature.json persistence. Also includes fixes from review feedback: - common.sh: _persist_feature_json uses json_escape fallback - common.ps1: Save-FeatureJson uses UTF8Encoding for PS 5.1 compat - common.ps1: case-sensitive path stripping on non-Windows - create-new-feature.sh/ps1: output both SPECIFY_FEATURE and SPECIFY_FEATURE_DIRECTORY - setup-tasks.sh: fix stale 'Validate branch' comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): update references to renamed git extension scripts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(tests): remove duplicate EXT_CREATE_FEATURE assignments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
583 lines
23 KiB
Bash
583 lines
23 KiB
Bash
#!/usr/bin/env bash
|
|
# Common functions and variables for all scripts
|
|
|
|
# Find repository root by searching upward for .specify directory
|
|
# This is the primary marker for spec-kit projects
|
|
find_specify_root() {
|
|
local dir="${1:-$(pwd)}"
|
|
# Normalize to absolute path to prevent infinite loop with relative paths
|
|
# Use -- to handle paths starting with - (e.g., -P, -L)
|
|
dir="$(cd -- "$dir" 2>/dev/null && pwd)" || return 1
|
|
local prev_dir=""
|
|
while true; do
|
|
if [ -d "$dir/.specify" ]; then
|
|
echo "$dir"
|
|
return 0
|
|
fi
|
|
# Stop if we've reached filesystem root or dirname stops changing
|
|
if [ "$dir" = "/" ] || [ "$dir" = "$prev_dir" ]; then
|
|
break
|
|
fi
|
|
prev_dir="$dir"
|
|
dir="$(dirname "$dir")"
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# Get repository root, prioritizing .specify directory
|
|
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
|
get_repo_root() {
|
|
# First, look for .specify directory (spec-kit's own marker)
|
|
local specify_root
|
|
if specify_root=$(find_specify_root); then
|
|
echo "$specify_root"
|
|
return
|
|
fi
|
|
|
|
# Final fallback to script location
|
|
local script_dir="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
(cd "$script_dir/../../.." && pwd)
|
|
}
|
|
|
|
# Get current feature name from explicit state only.
|
|
# Returns the feature identifier or empty string if none is set.
|
|
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
|
|
# the git extension) or implicitly via .specify/feature.json.
|
|
get_current_branch() {
|
|
if [[ -n "${SPECIFY_FEATURE:-}" ]]; then
|
|
echo "$SPECIFY_FEATURE"
|
|
return
|
|
fi
|
|
|
|
# No explicit feature set — caller must handle this via feature.json
|
|
# in get_feature_paths(). Return empty to signal "unknown".
|
|
echo ""
|
|
}
|
|
|
|
# Safely read .specify/feature.json's "feature_directory" value.
|
|
# Prints the raw value (possibly relative) to stdout, or empty string if the file
|
|
# is missing, unparseable, or does not contain the key. Always returns 0 so callers
|
|
# under `set -e` cannot be aborted by parser failure.
|
|
# Parser order mirrors the historical get_feature_paths behavior: jq -> python3 -> grep/sed.
|
|
read_feature_json_feature_directory() {
|
|
local repo_root="$1"
|
|
local fj="$repo_root/.specify/feature.json"
|
|
[[ -f "$fj" ]] || { printf '%s' ''; return 0; }
|
|
|
|
local _fd=''
|
|
if command -v jq >/dev/null 2>&1; then
|
|
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
|
|
_fd=''
|
|
fi
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
# Use Python so pretty-printed/multi-line JSON still parses correctly.
|
|
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
|
|
_fd=''
|
|
fi
|
|
else
|
|
# Last-resort single-line grep/sed fallback. The `|| true` guards against
|
|
# grep returning 1 (no match) aborting under `set -e` / `pipefail`.
|
|
_fd=$( { grep -E '"feature_directory"[[:space:]]*:' "$fj" 2>/dev/null || true; } \
|
|
| head -n 1 \
|
|
| sed -E 's/^[^:]*:[[:space:]]*"([^"]*)".*$/\1/' )
|
|
fi
|
|
|
|
printf '%s' "$_fd"
|
|
return 0
|
|
}
|
|
|
|
# Persist a feature_directory value to .specify/feature.json.
|
|
# Writes only when the file is missing or the value differs from what's stored.
|
|
# Accepts the raw (possibly relative) path — callers should pass the original
|
|
# user-supplied value, not the normalized absolute path.
|
|
_persist_feature_json() {
|
|
local repo_root="$1"
|
|
local feature_dir_value="$2"
|
|
local fj="$repo_root/.specify/feature.json"
|
|
|
|
# Strip repo_root prefix if the value is absolute and under repo_root
|
|
if [[ "$feature_dir_value" == "$repo_root/"* ]]; then
|
|
feature_dir_value="${feature_dir_value#"$repo_root/"}"
|
|
fi
|
|
|
|
# Read current value (if any) and skip write when unchanged
|
|
local current_val
|
|
current_val=$(read_feature_json_feature_directory "$repo_root")
|
|
if [[ "$current_val" == "$feature_dir_value" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
# Ensure .specify/ directory exists
|
|
mkdir -p "$repo_root/.specify"
|
|
|
|
# Write feature.json — prefer jq for safe JSON, fall back to printf
|
|
if command -v jq >/dev/null 2>&1; then
|
|
jq -cn --arg fd "$feature_dir_value" '{feature_directory:$fd}' > "$fj"
|
|
else
|
|
printf '{"feature_directory":"%s"}\n' "$(json_escape "$feature_dir_value")" > "$fj"
|
|
fi
|
|
}
|
|
|
|
get_feature_paths() {
|
|
local repo_root=$(get_repo_root)
|
|
local current_branch=$(get_current_branch)
|
|
|
|
# Resolve feature directory. Priority:
|
|
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
|
|
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
|
|
# 3. Error — no feature context available
|
|
local feature_dir
|
|
if [[ -n "${SPECIFY_FEATURE_DIRECTORY:-}" ]]; then
|
|
feature_dir="$SPECIFY_FEATURE_DIRECTORY"
|
|
# Normalize relative paths to absolute under repo root
|
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
|
# Persist to feature.json so future sessions without the env var still work
|
|
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
|
|
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
|
|
local _fd
|
|
_fd=$(read_feature_json_feature_directory "$repo_root")
|
|
if [[ -n "$_fd" ]]; then
|
|
feature_dir="$_fd"
|
|
# Normalize relative paths to absolute under repo root
|
|
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
|
|
else
|
|
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
|
|
return 1
|
|
fi
|
|
else
|
|
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." >&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 '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
|
|
}
|
|
|
|
get_invoke_separator() {
|
|
local repo_root="${1:-$(get_repo_root)}"
|
|
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
|
printf '%s\n' "$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
|
return 0
|
|
fi
|
|
|
|
local integration_json="$repo_root/.specify/integration.json"
|
|
local separator="."
|
|
local parsed_with_jq=0
|
|
|
|
if [[ -f "$integration_json" ]]; then
|
|
if command -v jq >/dev/null 2>&1; then
|
|
local jq_separator
|
|
if jq_separator=$(jq -r '(.default_integration // .integration // "") as $k | if $k == "" then "." else (.integration_settings[$k].invoke_separator // ".") end' "$integration_json" 2>/dev/null); then
|
|
parsed_with_jq=1
|
|
case "$jq_separator" in
|
|
"."|"-") separator="$jq_separator" ;;
|
|
esac
|
|
fi
|
|
fi
|
|
|
|
if [[ "$parsed_with_jq" -eq 0 ]] && command -v python3 >/dev/null 2>&1; then
|
|
if separator=$(python3 - "$integration_json" <<'PY' 2>/dev/null
|
|
import json
|
|
import sys
|
|
|
|
try:
|
|
with open(sys.argv[1], encoding="utf-8") as fh:
|
|
state = json.load(fh)
|
|
key = state.get("default_integration") or state.get("integration") or ""
|
|
settings = state.get("integration_settings")
|
|
separator = "."
|
|
if isinstance(key, str) and isinstance(settings, dict):
|
|
entry = settings.get(key)
|
|
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
|
separator = entry["invoke_separator"]
|
|
print(separator)
|
|
except Exception:
|
|
print(".")
|
|
PY
|
|
); then
|
|
case "$separator" in
|
|
"."|"-") ;;
|
|
*) separator="." ;;
|
|
esac
|
|
else
|
|
separator="."
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
|
printf '%s\n' "$separator"
|
|
}
|
|
|
|
format_speckit_command() {
|
|
local command_name="$1"
|
|
local repo_root="${2:-$(get_repo_root)}"
|
|
local separator
|
|
if [[ "${_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT:-}" == "$repo_root" && -n "${_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE:-}" ]]; then
|
|
separator="$_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE"
|
|
else
|
|
separator=$(get_invoke_separator "$repo_root")
|
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_REPO_ROOT="$repo_root"
|
|
_SPECIFY_INVOKE_SEPARATOR_CACHE_VALUE="$separator"
|
|
fi
|
|
|
|
command_name="${command_name#/}"
|
|
command_name="${command_name#speckit.}"
|
|
command_name="${command_name#speckit-}"
|
|
command_name="${command_name//./$separator}"
|
|
|
|
printf '/speckit%s%s\n' "$separator" "$command_name"
|
|
}
|
|
|
|
# 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}"
|
|
# Escape any remaining U+0001-U+001F control characters as \uXXXX.
|
|
# (U+0000/NUL cannot appear in bash strings and is excluded.)
|
|
# LC_ALL=C ensures ${#s} counts bytes and ${s:$i:1} yields single bytes,
|
|
# so multi-byte UTF-8 sequences (first byte >= 0xC0) pass through intact.
|
|
local LC_ALL=C
|
|
local i char code
|
|
for (( i=0; i<${#s}; i++ )); do
|
|
char="${s:$i:1}"
|
|
printf -v code '%d' "'$char" 2>/dev/null || code=256
|
|
if (( code >= 1 && code <= 31 )); then
|
|
printf '\\u%04x' "$code"
|
|
else
|
|
printf '%s' "$char"
|
|
fi
|
|
done
|
|
}
|
|
|
|
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) if isinstance(x[1], dict) else 10):
|
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
|
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
|
|
}
|
|
|
|
# Resolve a template name to composed content using composition strategies.
|
|
# Reads strategy metadata from preset manifests and composes content
|
|
# from multiple layers using prepend, append, or wrap strategies.
|
|
#
|
|
# Usage: CONTENT=$(resolve_template_content "template-name" "$REPO_ROOT")
|
|
# Returns composed content string on stdout; exit code 1 if not found.
|
|
resolve_template_content() {
|
|
local template_name="$1"
|
|
local repo_root="$2"
|
|
local base="$repo_root/.specify/templates"
|
|
|
|
# Collect all layers (highest priority first)
|
|
local -a layer_paths=()
|
|
local -a layer_strategies=()
|
|
|
|
# Priority 1: Project overrides (always "replace")
|
|
local override="$base/overrides/${template_name}.md"
|
|
if [ -f "$override" ]; then
|
|
layer_paths+=("$override")
|
|
layer_strategies+=("replace")
|
|
fi
|
|
|
|
# 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"
|
|
local sorted_presets=""
|
|
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
|
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) if isinstance(x[1], dict) else 10):
|
|
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
|
print(pid)
|
|
except Exception:
|
|
sys.exit(1)
|
|
" 2>/dev/null); then
|
|
if [ -n "$sorted_presets" ]; then
|
|
local yaml_warned=false
|
|
while IFS= read -r preset_id; do
|
|
# Read strategy and file path from preset manifest
|
|
local strategy="replace"
|
|
local manifest_file=""
|
|
local manifest="$presets_dir/$preset_id/preset.yml"
|
|
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
|
# Requires PyYAML; falls back to replace/convention if unavailable
|
|
local result
|
|
local py_stderr
|
|
py_stderr=$(mktemp)
|
|
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
|
|
import sys, os
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
print('yaml_missing', file=sys.stderr)
|
|
print('replace\t')
|
|
sys.exit(0)
|
|
try:
|
|
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
|
data = yaml.safe_load(f)
|
|
for t in data.get('provides', {}).get('templates', []):
|
|
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
|
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
|
sys.exit(0)
|
|
print('replace\t')
|
|
except Exception:
|
|
print('replace\t')
|
|
" 2>"$py_stderr")
|
|
local parse_status=$?
|
|
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
|
|
IFS=$'\t' read -r strategy manifest_file <<< "$result"
|
|
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
|
fi
|
|
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
|
|
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
|
|
yaml_warned=true
|
|
fi
|
|
rm -f "$py_stderr"
|
|
fi
|
|
# Try manifest file path first, then convention path
|
|
local candidate=""
|
|
if [ -n "$manifest_file" ]; then
|
|
# Reject absolute paths and parent traversal
|
|
case "$manifest_file" in
|
|
/*|*../*|../*) manifest_file="" ;;
|
|
esac
|
|
fi
|
|
if [ -n "$manifest_file" ]; then
|
|
local mf="$presets_dir/$preset_id/$manifest_file"
|
|
[ -f "$mf" ] && candidate="$mf"
|
|
fi
|
|
if [ -z "$candidate" ]; then
|
|
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
|
[ -f "$cf" ] && candidate="$cf"
|
|
fi
|
|
if [ -n "$candidate" ]; then
|
|
layer_paths+=("$candidate")
|
|
layer_strategies+=("$strategy")
|
|
fi
|
|
done <<< "$sorted_presets"
|
|
fi
|
|
else
|
|
# python3 failed — fall back to unordered directory scan (replace only)
|
|
for preset in "$presets_dir"/*/; do
|
|
[ -d "$preset" ] || continue
|
|
local candidate="$preset/templates/${template_name}.md"
|
|
if [ -f "$candidate" ]; then
|
|
layer_paths+=("$candidate")
|
|
layer_strategies+=("replace")
|
|
fi
|
|
done
|
|
fi
|
|
else
|
|
# No python3 or registry — fall back to unordered directory scan (replace only)
|
|
for preset in "$presets_dir"/*/; do
|
|
[ -d "$preset" ] || continue
|
|
local candidate="$preset/templates/${template_name}.md"
|
|
if [ -f "$candidate" ]; then
|
|
layer_paths+=("$candidate")
|
|
layer_strategies+=("replace")
|
|
fi
|
|
done
|
|
fi
|
|
fi
|
|
|
|
# Priority 3: Extension-provided templates (always "replace")
|
|
local ext_dir="$repo_root/.specify/extensions"
|
|
if [ -d "$ext_dir" ]; then
|
|
for ext in "$ext_dir"/*/; do
|
|
[ -d "$ext" ] || continue
|
|
case "$(basename "$ext")" in .*) continue;; esac
|
|
local candidate="$ext/templates/${template_name}.md"
|
|
if [ -f "$candidate" ]; then
|
|
layer_paths+=("$candidate")
|
|
layer_strategies+=("replace")
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Priority 4: Core templates (always "replace")
|
|
local core="$base/${template_name}.md"
|
|
if [ -f "$core" ]; then
|
|
layer_paths+=("$core")
|
|
layer_strategies+=("replace")
|
|
fi
|
|
|
|
local count=${#layer_paths[@]}
|
|
[ "$count" -eq 0 ] && return 1
|
|
|
|
# Check if any layer uses a non-replace strategy
|
|
local has_composition=false
|
|
for s in "${layer_strategies[@]}"; do
|
|
[ "$s" != "replace" ] && has_composition=true && break
|
|
done
|
|
|
|
# If the top (highest-priority) layer is replace, it wins entirely —
|
|
# lower layers are irrelevant regardless of their strategies.
|
|
if [ "${layer_strategies[0]}" = "replace" ]; then
|
|
cat "${layer_paths[0]}"
|
|
return 0
|
|
fi
|
|
|
|
if [ "$has_composition" = false ]; then
|
|
cat "${layer_paths[0]}"
|
|
return 0
|
|
fi
|
|
|
|
# Find the effective base: scan from highest priority (index 0) downward
|
|
# to find the nearest replace layer. Only compose layers above that base.
|
|
local base_idx=-1
|
|
local i
|
|
for (( i=0; i<count; i++ )); do
|
|
if [ "${layer_strategies[$i]}" = "replace" ]; then
|
|
base_idx=$i
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [ $base_idx -lt 0 ]; then
|
|
return 1 # no base layer found
|
|
fi
|
|
|
|
# Read the base content; compose layers above the base (higher priority)
|
|
local content
|
|
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
|
content="${content%x}"
|
|
|
|
for (( i=base_idx-1; i>=0; i-- )); do
|
|
local path="${layer_paths[$i]}"
|
|
local strat="${layer_strategies[$i]}"
|
|
local layer_content
|
|
# Preserve trailing newlines
|
|
layer_content=$(cat "$path"; printf x)
|
|
layer_content="${layer_content%x}"
|
|
|
|
case "$strat" in
|
|
replace) content="$layer_content" ;;
|
|
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
|
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
|
|
wrap)
|
|
case "$layer_content" in
|
|
*'{CORE_TEMPLATE}'*) ;;
|
|
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
|
esac
|
|
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
|
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
|
local after="${layer_content#*\{CORE_TEMPLATE\}}"
|
|
layer_content="${before}${content}${after}"
|
|
done
|
|
content="$layer_content"
|
|
;;
|
|
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
|
esac
|
|
done
|
|
|
|
printf '%s' "$content"
|
|
return 0
|
|
}
|