Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/reference/core.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ specify init my-project --integration copilot --preset compliance
| `SPECIFY_FEATURE` | Explicitly override the active feature **label** (e.g. `001-photo-albums`) — the identifier the core helpers report as the current feature/branch (`get_current_branch` in Bash, `Get-CurrentBranch` in PowerShell). Those helpers never inspect Git: when the variable is set they return it verbatim, and when it is unset they return an empty string. The basename fallback happens later — `get_feature_paths` / `Get-FeaturePathsEnv` substitute the resolved feature directory's basename so the reported label is still usable — so calling the named helpers directly does **not** give you that fallback. You set it yourself: the Bash and Python feature scripts only **print** a commented `export SPECIFY_FEATURE=…` / `$env:SPECIFY_FEATURE = …` hint for you to run, because a child process cannot change its parent's environment, and `/speckit.specify` persists `feature_directory` to `.specify/feature.json` instead of setting this variable. (The PowerShell feature scripts do assign `$env:SPECIFY_FEATURE`, but that only reaches you when the script runs inside your current PowerShell session.) It does **not** locate the feature directory: with only `SPECIFY_FEATURE` set, `get_feature_paths` fails with *"Feature directory not found. Set `SPECIFY_FEATURE_DIRECTORY` or run the specify command to create `.specify/feature.json`."* Use `SPECIFY_FEATURE_DIRECTORY` (above) or `.specify/feature.json` to select the directory. |
| `SPECIFY_FEATURE_NO_PERSIST` | Set to `1` or `true` to stop every core script from writing `.specify/feature.json`, even when it would otherwise persist `SPECIFY_FEATURE_DIRECTORY` on read. Useful when multiple agents run concurrently against the same checkout, each with its own `SPECIFY_FEATURE_DIRECTORY`: without it, each invocation's persist step can overwrite another agent's pinned feature directory. |

> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** — its default location is under `specs/` in that project, but an explicitly selected feature directory is not restricted to living inside it. They are independent — project first, then feature.
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature**. Paths loaded from `.specify/feature.json` must resolve inside the project, including after resolving symbolic links and `..` components. External feature directories require an explicit `SPECIFY_FEATURE_DIRECTORY` override on every invocation, even if a previous override was persisted to `feature.json`. Plan setup rejects symbolic links at the destination `plan.md` and creates new plans exclusively, without overwriting existing files. Bash plan creation uses `link(1)` to publish the completed plan atomically and requires a filesystem that supports hard links.
>
> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
>
Expand Down
64 changes: 58 additions & 6 deletions scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,16 +104,21 @@ read_feature_json_feature_directory() {
# an availability-gated `elif` would pick python3, swallow its failure, and
# never reach the grep/sed fallback -- leaving feature.json unreadable even
# though it is valid (issue #3304).
# A success marker preserves trailing newlines in parsed path values.
local _fd=''
if command -v jq >/dev/null 2>&1; then
if ! _fd=$(jq -r '.feature_directory // empty' "$fj" 2>/dev/null); then
if ! _fd=$(jq -jr '.feature_directory // empty' "$fj" 2>/dev/null && printf '.'); then
_fd=''
else
_fd="${_fd%.}"
fi
fi
if [[ -z "$_fd" ]] && 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], encoding='utf-8')); v=d.get('feature_directory'); print(v if v else '')" "$fj" 2>/dev/null); then
if ! _fd=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1], encoding='utf-8')); v=d.get('feature_directory'); print(v if v else '', end='')" "$fj" 2>/dev/null && printf '.'); then
_fd=''
else
_fd="${_fd%.}"
fi
fi
if [[ -z "$_fd" ]]; then
Expand Down Expand Up @@ -144,7 +149,8 @@ _persist_feature_json() {

# Read current value (if any) and skip write when unchanged
local current_val
current_val=$(read_feature_json_feature_directory "$repo_root")
current_val=$(read_feature_json_feature_directory "$repo_root" && printf '.')
current_val="${current_val%.}"
if [[ "$current_val" == "$feature_dir_value" ]]; then
return 0
fi
Expand All @@ -160,6 +166,42 @@ _persist_feature_json() {
fi
}

# Resolve existing links and missing directory suffixes without creating anything.
_resolve_feature_directory() {
local remaining="$1" resolved="/" component target links=0
while [[ -n "$remaining" ]]; do
component="${remaining%%/*}"
if [[ "$remaining" == */* ]]; then
remaining="${remaining#*/}"
else
remaining=""
fi
case "$component" in
""|.) continue ;;
..)
resolved="${resolved%/*}"
resolved="${resolved:-/}"
continue
;;
esac
target="${resolved%/}/$component"
if [[ -L "$target" ]]; then
links=$((links + 1))
[[ "$links" -le 40 ]] || return 1
# The marker retains target newlines across command substitution.
target="$(readlink -n -- "$target" && printf '.')" || return 1
target="${target%.}"
remaining="$target${remaining:+/$remaining}"
[[ "$target" != /* ]] || resolved="/"
elif [[ -e "$target" && ! -d "$target" ]]; then
return 1
else
resolved="$target"
fi
done
printf '%s' "$resolved"
}

get_feature_paths() {
# Read-only callers (e.g. check-prerequisites.sh --paths-only) pass
# --no-persist so pure path resolution never writes .specify/feature.json,
Expand Down Expand Up @@ -193,18 +235,28 @@ get_feature_paths() {
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 — unless the caller opted out for read-only resolution (#3025).
# Persist unless read-only resolution was requested (#3025). External
# paths still require the explicit override on subsequent invocations.
if [[ "$no_persist" != true ]]; then
_persist_feature_json "$repo_root" "$SPECIFY_FEATURE_DIRECTORY"
fi
elif [[ -f "$repo_root/.specify/feature.json" ]]; then
local _fd
_fd=$(read_feature_json_feature_directory "$repo_root")
_fd=$(read_feature_json_feature_directory "$repo_root" && printf '.')
_fd="${_fd%.}"
if [[ -n "$_fd" ]]; then
feature_dir="$_fd"
# Normalize relative paths to absolute under repo root
[[ "$feature_dir" != /* ]] && feature_dir="$repo_root/$feature_dir"
local physical_root
physical_root=$(CDPATH="" cd -- "$repo_root" && pwd -P) || return 1
local resolved_feature_dir
if ! resolved_feature_dir=$(_resolve_feature_directory "$feature_dir" && printf '.') ||
[[ "${resolved_feature_dir%.}" != "$physical_root" && "${resolved_feature_dir%.}" != "${physical_root%/}/"* ]]; then
echo "ERROR: Stored feature directory is invalid or outside the project. Set SPECIFY_FEATURE_DIRECTORY explicitly to use an external directory." >&2
return 1
fi
feature_dir="${resolved_feature_dir%.}"
else
echo "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." >&2
return 1
Expand Down
16 changes: 13 additions & 3 deletions scripts/bash/setup-plan.sh
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ unset _paths_output
# Ensure the feature directory exists
mkdir -p "$FEATURE_DIR"

if [[ -L "$IMPL_PLAN" ]]; then
echo "ERROR: Plan destination must not be a symbolic link." >&2
exit 1
fi

# Copy plan template if plan doesn't already exist
if [[ -f "$IMPL_PLAN" ]]; then
if $JSON_MODE; then
Expand All @@ -43,15 +48,20 @@ if [[ -f "$IMPL_PLAN" ]]; then
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
fi
else
template_file=$(mktemp)
trap 'rm -f "$template_file"' EXIT
template_dir=$(mktemp -d "$FEATURE_DIR/.plan-template.XXXXXX")
template_file="$template_dir/plan.md"
trap 'rm -f "$template_file"; rmdir "$template_dir"' EXIT
if resolve_template_content "plan-template" "$REPO_ROOT" > "$template_file"; then
if [[ ! -s "$template_file" ]]; then
echo "ERROR: Plan template is empty; no new plan.md was created." >&2
echo "Restore a valid plan template and rerun plan setup." >&2
exit 1
fi
cat "$template_file" > "$IMPL_PLAN"
# link(1) publishes atomically without opening any existing destination.
if ! link "$template_file" "$IMPL_PLAN"; then
echo "ERROR: Cannot create plan: $IMPL_PLAN" >&2
exit 1
fi
if $JSON_MODE; then
echo "Copied plan template to $IMPL_PLAN" >&2
else
Expand Down
76 changes: 74 additions & 2 deletions scripts/powershell/common.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,61 @@ function Save-FeatureJson {
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
}

function Test-NameRedirectingLink {
param([object]$Entry)

return ($Entry -and ($Entry.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -and
($Entry.LinkType -in @('SymbolicLink', 'Junction') -or $Entry.Target))
}

function Resolve-FeatureDirectoryPath {
param([string]$Path, [ref]$LinkCount = ([ref]0))

$root = [System.IO.Path]::GetPathRoot($Path)
if (-not $root) { throw 'Feature directory must be absolute' }
$resolved = $root
$pathComparison = [System.StringComparison]::Ordinal
if ([System.IO.Path]::DirectorySeparatorChar -eq '\') {
$pathComparison = [System.StringComparison]::OrdinalIgnoreCase
}
# The PowerShell FileSystem provider recognizes both separators on every OS.
foreach ($component in ($Path.Substring($root.Length) -split '[\\/]')) {
if (-not $component -or $component -eq '.') { continue }
if ($component -eq '..') {
$parent = [System.IO.Path]::GetDirectoryName($resolved.TrimEnd('/', '\'))
$resolved = if ($parent) { $parent } else { $root }
continue
}
$candidate = Join-Path $resolved $component
$entry = $null
if (Test-Path -LiteralPath $candidate) {
# Get-Item recognizes Windows short-name and trailing-dot aliases too.
$entry = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop
} elseif (Test-Path -LiteralPath $resolved -PathType Container) {
# Parent enumeration also sees dangling links on Windows PowerShell 5.1.
$entry = Get-ChildItem -LiteralPath $resolved -Force -ErrorAction Stop |
Where-Object { $_.Name.Equals($component, $pathComparison) } |
Select-Object -First 1
}
# Cloud placeholders can be reparse directories without redirecting names.
if (Test-NameRedirectingLink -Entry $entry) {
$LinkCount.Value += 1
if ($LinkCount.Value -gt 40) { throw 'Too many symbolic links in feature directory' }
$target = @($entry.Target)[0]
if (-not $target) { throw "Cannot resolve reparse point: $candidate" }
if (-not [System.IO.Path]::IsPathRooted($target)) {
$target = Join-Path $resolved $target
}
$resolved = Resolve-FeatureDirectoryPath -Path $target -LinkCount $LinkCount
} elseif ($entry -and -not $entry.PSIsContainer) {
throw "Feature directory component is not a directory: $candidate"
} else {
$resolved = $candidate
}
}
return $resolved
}

function Get-FeaturePathsEnv {
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
# so pure path resolution never writes .specify/feature.json, which would
Expand Down Expand Up @@ -188,8 +243,8 @@ function Get-FeaturePathsEnv {
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
# Persist to feature.json so future sessions without the env var still
# work - unless the caller opted out for read-only resolution (#3025).
# Persist unless read-only resolution was requested (#3025). External
# paths still require the explicit override on subsequent invocations.
if (-not $noPersist) {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
Expand All @@ -208,6 +263,23 @@ function Get-FeaturePathsEnv {
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
try {
$featureDir = Resolve-FeatureDirectoryPath -Path $featureDir
$physicalRoot = Resolve-FeatureDirectoryPath -Path $repoRoot
$prefix = $physicalRoot.TrimEnd('/', '\') + [System.IO.Path]::DirectorySeparatorChar
$comparison = [System.StringComparison]::Ordinal
if ([System.IO.Path]::DirectorySeparatorChar -eq '\') {
$comparison = [System.StringComparison]::OrdinalIgnoreCase
}
if (-not $featureDir.Equals($physicalRoot, $comparison) -and
-not $featureDir.StartsWith($prefix, $comparison)) {
throw 'Feature directory escapes the project'
}
} catch {
[Console]::Error.WriteLine("ERROR: Stored feature directory is invalid or outside the project. Set SPECIFY_FEATURE_DIRECTORY explicitly to use an external directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
Expand Down
32 changes: 31 additions & 1 deletion scripts/powershell/setup-plan.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,22 @@ if (-not $paths) {
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null

$pathComparison = [System.StringComparison]::Ordinal
if ([System.IO.Path]::DirectorySeparatorChar -eq '\') {
$pathComparison = [System.StringComparison]::OrdinalIgnoreCase
}
try {
$planEntry = Get-Item -LiteralPath $paths.IMPL_PLAN -Force -ErrorAction Stop
} catch [System.Management.Automation.ItemNotFoundException] {
# Windows PowerShell 5.1 may only expose dangling links through their parent.
$planEntry = Get-ChildItem -LiteralPath $paths.FEATURE_DIR -Force -ErrorAction Stop |
Where-Object { $_.Name.Equals('plan.md', $pathComparison) } | Select-Object -First 1
}
if (Test-NameRedirectingLink -Entry $planEntry) {
[Console]::Error.WriteLine("ERROR: Plan destination must not be a symbolic link.")
exit 1
}

# Copy plan template if plan doesn't already exist
if (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf) {
if ($Json) {
Expand All @@ -56,7 +72,21 @@ if (Test-Path -LiteralPath $paths.IMPL_PLAN -PathType Leaf) {
exit 1
}
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
try {
$plan = [System.IO.File]::Open(
$paths.IMPL_PLAN, [System.IO.FileMode]::CreateNew,
[System.IO.FileAccess]::Write, [System.IO.FileShare]::None
)
try {
$bytes = $utf8NoBom.GetBytes($content)
$plan.Write($bytes, 0, $bytes.Length)
} finally {
$plan.Dispose()
}
} catch {
[Console]::Error.WriteLine("ERROR: Cannot create plan: $($_.Exception.Message)")
exit 1
}
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
# in -Json mode so stdout stays pure JSON, matching the sibling messages.
if ($Json) {
Expand Down
14 changes: 14 additions & 0 deletions scripts/python/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,20 @@ def get_feature_paths(
feature_dir = Path(stored)
if not feature_dir.is_absolute():
feature_dir = repo_root / feature_dir
try:
try:
feature_dir = feature_dir.resolve(strict=True)
except FileNotFoundError:
# Missing feature directories are valid; other resolution errors are not.
feature_dir = feature_dir.resolve()
feature_dir.relative_to(repo_root.resolve())
except (OSError, RuntimeError, ValueError) as exc:
print(
"ERROR: Stored feature directory is invalid or outside the project. "
"Set SPECIFY_FEATURE_DIRECTORY explicitly to use an external directory.",
file=sys.stderr,
)
raise SystemExit(1) from exc
else:
print(
"ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY "
Expand Down
11 changes: 10 additions & 1 deletion scripts/python/setup_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ def main(argv: list[str] | None = None) -> int:

paths.feature_dir.mkdir(parents=True, exist_ok=True)

if paths.impl_plan.is_symlink():
print("ERROR: Plan destination must not be a symbolic link.", file=sys.stderr)
return 1

# Status messages go to stderr in JSON mode so stdout stays pure JSON.
status_stream = sys.stderr if json_mode else sys.stdout
if paths.impl_plan.is_file():
Expand All @@ -81,7 +85,12 @@ def main(argv: list[str] | None = None) -> int:
file=sys.stderr,
)
return 1
paths.impl_plan.write_bytes(template_content.encode("utf-8"))
try:
with paths.impl_plan.open("xb") as plan:
plan.write(template_content.encode("utf-8"))
except OSError as exc:
print(f"ERROR: Cannot create plan: {exc}", file=sys.stderr)
return 1
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
else:
print(
Expand Down
Loading
Loading