2025-09-12 10:27:43 -07:00
#!/usr/bin/env pwsh
2025-09-20 12:14:42 -07:00
# Common PowerShell functions analogous to common.sh
2025-09-12 10:27:43 -07:00
2026-03-24 14:55:21 +01:00
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
function Find-SpecifyRoot {
param ([ string ] $StartDir = ( Get-Location ). Path )
# Normalize to absolute path to prevent issues with relative paths
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
2026-03-25 12:54:49 -05:00
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
$current = if ( $resolved ) { $resolved . Path } else { $null }
2026-03-24 14:55:21 +01:00
if ( -not $current ) { return $null }
while ( $true ) {
if ( Test-Path -LiteralPath ( Join-Path $current ".specify" ) -PathType Container ) {
return $current
}
$parent = Split-Path $current -Parent
if ([ string ]:: IsNullOrEmpty ( $parent ) -or $parent -eq $current ) {
return $null
}
$current = $parent
}
}
2026-06-19 19:05:42 +02:00
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
2026-07-21 18:40:15 +02:00
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
# design: the path must exist and
2026-06-19 19:05:42 +02:00
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# This is the single resolver: bundled extensions inherit it by sourcing core
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
function Resolve-SpecifyInitDir {
2026-07-21 18:40:15 +02:00
param ([ switch ] $ReturnNullOnError )
2026-06-19 19:05:42 +02:00
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if ( -not [ System.IO.Path ]:: IsPathRooted ( $initDir )) {
$initDir = Join-Path ( Get-Location ). Path $initDir
}
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
# Resolve-Path also succeeds for files, so check the resolved path is a
# directory; otherwise a file value would slip through to the less accurate
# "not a Spec Kit project" error below.
if ( -not $resolved -or -not ( Test-Path -LiteralPath $resolved . Path -PathType Container )) {
[ Console ]:: Error . WriteLine ( "ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $( $env:SPECIFY_INIT_DIR ) " )
2026-07-21 18:40:15 +02:00
if ( $ReturnNullOnError ) { return $null }
2026-06-19 19:05:42 +02:00
exit 1
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
# the returned root matches the bash resolver, whose `cd && pwd` never yields
2026-07-30 17:51:38 +05:00
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
# become 'C:', which every later API re-resolves against the current
# directory instead of the drive root). No-op on a path with no trailing
# separator.
$initRoot = $resolved . Path . TrimEnd ( '/' , '\' )
if ( $initRoot . Length -lt [ System.IO.Path ]:: GetPathRoot ( $resolved . Path ). Length ) {
$initRoot = $resolved . Path
}
2026-06-19 19:05:42 +02:00
if ( -not ( Test-Path -LiteralPath ( Join-Path $initRoot '.specify' ) -PathType Container )) {
[ Console ]:: Error . WriteLine ( "ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot " )
2026-07-21 18:40:15 +02:00
if ( $ReturnNullOnError ) { return $null }
2026-06-19 19:05:42 +02:00
exit 1
}
return $initRoot
}
2026-06-09 06:13:07 -05:00
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
2025-09-12 10:27:43 -07:00
function Get-RepoRoot {
2026-07-21 18:40:15 +02:00
param ([ switch ] $ReturnNullOnError )
2026-06-19 19:05:42 +02:00
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ( $env:SPECIFY_INIT_DIR ) {
2026-07-21 18:40:15 +02:00
return ( Resolve-SpecifyInitDir -ReturnNullOnError: $ReturnNullOnError )
2026-06-19 19:05:42 +02:00
}
2026-03-24 14:55:21 +01:00
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ( $specifyRoot ) {
return $specifyRoot
}
2026-06-09 06:13:07 -05:00
# Final fallback to script location
2026-03-24 14:55:21 +01:00
# Use -LiteralPath to handle paths with wildcard characters
return ( Resolve-Path -LiteralPath ( Join-Path $PSScriptRoot "../../.." )). Path
2025-09-12 10:27:43 -07:00
}
function Get-CurrentBranch {
2026-06-09 06:13:07 -05:00
# Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
2025-09-20 15:04:25 -07:00
if ( $env:SPECIFY_FEATURE ) {
return $env:SPECIFY_FEATURE
}
2026-03-24 14:55:21 +01:00
2026-06-09 06:13:07 -05:00
# No explicit feature set - return empty to signal "unknown".
return ""
}
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param (
[ Parameter ( Mandatory = $true )][ string ] $RepoRoot ,
[ Parameter ( Mandatory = $true )][ string ] $FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [ System.IO.Path ]:: DirectorySeparatorChar
if ( $null -ne $IsWindows ) { $onWin = $IsWindows } else { $onWin = $true }
if ( $onWin ) {
$cmp = [ System.StringComparison ]:: OrdinalIgnoreCase
} else {
$cmp = [ System.StringComparison ]:: Ordinal
}
if ( $FeatureDirectory . StartsWith ( $prefix , $cmp )) {
$FeatureDirectory = $FeatureDirectory . Substring ( $prefix . Length )
}
$fjPath = Join-Path ( Join-Path $RepoRoot '.specify' ) 'feature.json'
# Read current value and skip write when unchanged
if ( Test-Path -LiteralPath $fjPath -PathType Leaf ) {
2026-03-24 14:55:21 +01:00
try {
2026-06-09 06:13:07 -05:00
$raw = Get-Content -LiteralPath $fjPath -Raw
$cfg = $raw | ConvertFrom-Json
if ( $cfg . feature_directory -eq $FeatureDirectory ) {
return
2026-03-24 14:55:21 +01:00
}
} catch {
2026-06-09 06:13:07 -05:00
# File is corrupt or unreadable - overwrite it
2026-03-24 14:55:21 +01:00
}
}
2026-06-09 06:13:07 -05:00
# Ensure .specify/ directory exists
$specifyDir = Join-Path $RepoRoot '.specify'
if ( -not ( Test-Path -LiteralPath $specifyDir -PathType Container )) {
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
2025-09-20 12:14:42 -07:00
}
2026-04-13 20:11:17 -03:00
2026-06-09 06:13:07 -05:00
# Write feature.json
$json = @ { feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System . Text . UTF8Encoding ( $false )
[ System.IO.File ]:: WriteAllText ( $fjPath , $json , $utf8NoBom )
2025-09-12 10:27:43 -07:00
}
function Get-FeaturePathsEnv {
2026-06-30 16:38:59 +05:00
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param (
2026-07-21 18:40:15 +02:00
[ switch ] $NoPersist ,
[ switch ] $ReturnNullOnError
2026-06-30 16:38:59 +05:00
)
2026-07-21 18:40:15 +02:00
$repoRoot = Get-RepoRoot -ReturnNullOnError: $ReturnNullOnError
if ( -not $repoRoot ) { return $null }
2025-09-12 10:27:43 -07:00
$currentBranch = Get-CurrentBranch
2026-04-08 13:48:36 -05:00
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
2026-06-09 06:13:07 -05:00
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Error - no feature context available
2026-04-08 13:48:36 -05:00
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ( $env:SPECIFY_FEATURE_DIRECTORY ) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
# Normalize relative paths to absolute under repo root
if ( -not [ System.IO.Path ]:: IsPathRooted ( $featureDir )) {
$featureDir = Join-Path $repoRoot $featureDir
}
2026-06-30 16:38:59 +05:00
# Persist to feature.json so future sessions without the env var still
# work - unless the caller opted out for read-only resolution (#3025).
if ( -not $NoPersist ) {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
2026-04-08 13:48:36 -05:00
} elseif ( Test-Path $featureJson ) {
2026-04-13 20:11:17 -03:00
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
2026-04-08 13:48:36 -05:00
try {
2026-04-13 20:11:17 -03:00
$featureConfig = $featureJsonRaw | ConvertFrom-Json
2026-04-08 13:48:36 -05:00
} catch {
2026-07-21 18:40:15 +02:00
[ Console ]:: Error . WriteLine ( "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." )
if ( $ReturnNullOnError ) { return $null }
2026-04-13 20:11:17 -03:00
exit 1
}
if ( $featureConfig . feature_directory ) {
$featureDir = $featureConfig . feature_directory
# Normalize relative paths to absolute under repo root
if ( -not [ System.IO.Path ]:: IsPathRooted ( $featureDir )) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
2026-06-09 06:13:07 -05:00
[ Console ]:: Error . WriteLine ( "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory." )
2026-07-21 18:40:15 +02:00
if ( $ReturnNullOnError ) { return $null }
2026-06-09 06:13:07 -05:00
exit 1
2026-04-08 13:48:36 -05:00
}
} else {
2026-06-09 06:13:07 -05:00
[ Console ]:: Error . WriteLine ( "ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json." )
2026-07-21 18:40:15 +02:00
if ( $ReturnNullOnError ) { return $null }
2026-06-09 06:13:07 -05:00
exit 1
2026-04-08 13:48:36 -05:00
}
2026-07-10 12:47:36 -04:00
2026-07-01 23:50:53 +05:00
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than
# an empty, misleading value (issue #3026).
if ( -not $currentBranch ) {
# TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework.
$featureDirTrimmed = $featureDir . TrimEnd ( '/' , '\' )
$currentBranch = Split-Path -Leaf $featureDirTrimmed
}
2025-09-12 10:27:43 -07:00
[ PSCustomObject ] @ {
2025-09-20 12:14:42 -07:00
REPO_ROOT = $repoRoot
2025-09-12 10:27:43 -07:00
CURRENT_BRANCH = $currentBranch
2025-09-20 12:14:42 -07:00
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
2025-09-12 10:27:43 -07:00
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param ([ string ] $Path , [ string ] $Description )
if ( Test-Path -Path $Path -PathType Leaf ) {
2026-05-26 13:18:34 -05:00
Write-Output " [OK] $Description "
2025-09-12 10:27:43 -07:00
return $true
} else {
2026-05-26 13:18:34 -05:00
Write-Output " [FAIL] $Description "
2025-09-12 10:27:43 -07:00
return $false
}
}
function Test-DirHasFiles {
param ([ string ] $Path , [ string ] $Description )
2026-06-30 00:38:39 +05:00
# A directory counts as non-empty when Get-ChildItem returns any entry
# (files or subdirectories) -- matching the JSON contracts checks in
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
# non-empty like bash check_dir. Filtering out subdirectories would
# mis-report such a directory as empty.
if (( Test-Path -Path $Path -PathType Container ) -and ( Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1 )) {
2026-05-26 13:18:34 -05:00
Write-Output " [OK] $Description "
2025-09-12 10:27:43 -07:00
return $true
} else {
2026-05-26 13:18:34 -05:00
Write-Output " [FAIL] $Description "
2025-09-12 10:27:43 -07:00
return $false
}
}
2025-10-14 11:52:26 -07:00
2026-06-03 23:24:13 +02:00
function Get-InvokeSeparator {
param ([ string ] $RepoRoot = ( Get-RepoRoot ))
if ( $null -eq $script:SpecKitInvokeSeparatorCache ) {
$script:SpecKitInvokeSeparatorCache = @ {}
}
if ( $script:SpecKitInvokeSeparatorCache . ContainsKey ( $RepoRoot )) {
return $script:SpecKitInvokeSeparatorCache [ $RepoRoot ]
}
$separator = '.'
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
if ( Test-Path -LiteralPath $integrationJson -PathType Leaf ) {
try {
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
$key = if ( $state . default_integration ) { [ string ] $state . default_integration } elseif ( $state . integration ) { [ string ] $state . integration } else { '' }
if ( $key -and $state . integration_settings ) {
$settingProperty = $state . integration_settings . PSObject . Properties [ $key ]
if ( $settingProperty ) {
$setting = $settingProperty . Value
if ( $setting -and ( $setting . invoke_separator -eq '.' -or $setting . invoke_separator -eq '-' )) {
$separator = [ string ] $setting . invoke_separator
}
}
}
} catch {
$separator = '.'
}
}
$script:SpecKitInvokeSeparatorCache [ $RepoRoot ] = $separator
return $separator
}
function Format-SpecKitCommand {
param (
[ Parameter ( Mandatory = $true )][ string ] $CommandName ,
[ string ] $RepoRoot = ( Get-RepoRoot )
)
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
$name = $CommandName . TrimStart ( '/' )
if ( $name . StartsWith ( 'speckit.' )) {
$name = $name . Substring ( 8 )
} elseif ( $name . StartsWith ( 'speckit-' )) {
$name = $name . Substring ( 8 )
}
$name = $name -replace '\.' , $separator
return "/speckit $separator$name "
}
2026-04-23 10:07:52 -05:00
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {
if ( Get-Command python3 -ErrorAction SilentlyContinue ) { return @ ( 'python3' ) }
if ( Get-Command python -ErrorAction SilentlyContinue ) {
$ver = & python - -version 2 >& 1
if ( $ver -match 'Python 3' ) { return @ ( 'python' ) }
}
if ( Get-Command py -ErrorAction SilentlyContinue ) {
$ver = & py -3 - -version 2 >& 1
if ( $ver -match 'Python 3' ) { return @ ( 'py' , '-3' ) }
}
return $null
}
2026-08-10 10:22:31 -05:00
function Get-NormalizedPriority {
param ( $Value )
if ( $Value -is [ bool ]) { return 10 }
if ( $Value -is [ string ]) {
$integerText = $Value . Trim ()
if ( $integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$' ) { return 10 }
$Value = $integerText . Replace ( '_' , '' )
}
try {
$parsedPriority = [ System.Numerics.BigInteger ] $Value
} catch {
return 10
}
return $ ( if ( $parsedPriority -ge 1 ) { $parsedPriority } else { 10 })
}
function Get-SortedExtensionIds {
param ([ Parameter ( Mandatory = $true )][ string ] $ExtensionsDir )
$registeredNames = @ ()
$ranked = @ ()
$registryFile = Join-Path $ExtensionsDir '.registry'
# Detect any filesystem entry at the registry path without following symlinks.
# Test-Path follows links and reports $false for a dangling symlink, so a
# broken .registry symlink would otherwise bypass this guard and let the
# directory scan below enable every on-disk extension. Enumerating the parent
# directory still observes a broken symlink as an entry.
$registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue |
Where-Object { $_ . Name -eq '.registry' } |
Select-Object -First 1
if ( $registryEntry ) {
if ( -not ( Test-Path -LiteralPath $registryFile -PathType Leaf )) {
throw "Invalid extension registry ${registryFile} : not a regular file"
}
try {
$data = [ System.IO.File ]:: ReadAllText ( $registryFile , [ System.Text.Encoding ]:: UTF8 ) | ConvertFrom-Json
} catch {
throw "Invalid extension registry ${registryFile} : $( $_ . Exception . Message ) "
}
if ( $null -eq $data -or $data -isnot [ PSCustomObject ]) {
throw "Invalid extension registry ${registryFile} : root must be a mapping"
}
$extensionsProperty = $data . PSObject . Properties [ 'extensions' ]
if ( $extensionsProperty ) {
if ( $extensionsProperty . Value -isnot [ PSCustomObject ]) {
throw "Invalid extension registry ${registryFile} : 'extensions' must be a mapping"
}
$extensions = $extensionsProperty . Value
} else {
$extensions = [ PSCustomObject ] @ {}
}
$registeredNames = @ ( $extensions . PSObject . Properties | ForEach-Object { $_ . Name })
foreach ( $entry in $extensions . PSObject . Properties ) {
if ( $entry . Name -cnotmatch '^[a-z0-9-]+$' -or $entry . Value -isnot [ PSCustomObject ]) {
continue
}
$enabledProperty = $entry . Value . PSObject . Properties [ 'enabled' ]
if ( $enabledProperty -and -not [ bool ] $enabledProperty . Value ) { continue }
$priority = 10
$priorityProperty = $entry . Value . PSObject . Properties [ 'priority' ]
if ( $priorityProperty ) {
$priority = Get-NormalizedPriority -Value $priorityProperty . Value
}
$ranked += [ PSCustomObject ] @ { Priority = $priority ; Id = $entry . Name }
}
}
foreach ( $directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue ) {
if ( $directory . Name -cmatch '^[a-z0-9-]+$' -and $directory . Name -cnotin $registeredNames ) {
$ranked += [ PSCustomObject ] @ { Priority = 10 ; Id = $directory . Name }
}
}
return $ranked | Sort-Object Priority , Id | ForEach-Object { $_ . Id }
}
2026-03-13 15:09:14 -05:00
# 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)
function Resolve-Template {
param (
[ Parameter ( Mandatory = $true )][ string ] $TemplateName ,
[ Parameter ( Mandatory = $true )][ string ] $RepoRoot
)
2026-08-10 10:22:31 -05:00
if ( $TemplateName -cnotmatch '^[a-z0-9-]+$' ) { return $null }
2026-03-13 15:09:14 -05:00
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
$override = Join-Path $base "overrides/ $TemplateName .md"
if ( Test-Path $override ) { return $override }
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if ( Test-Path $presetsDir ) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @ ()
2026-07-21 18:40:15 +02:00
$registryParsed = $false
2026-03-13 15:09:14 -05:00
if ( Test-Path $registryFile ) {
try {
2026-08-10 10:22:31 -05:00
$registryData = [ System.IO.File ]:: ReadAllText ( $registryFile , [ System.Text.Encoding ]:: UTF8 ) | ConvertFrom-Json
2026-07-21 18:40:15 +02:00
if ( $null -eq $registryData -or $registryData -isnot [ PSCustomObject ]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData . PSObject . Properties [ 'presets' ]
if ( $presetsProperty ) {
$presets = $presetsProperty . Value
if ( $null -eq $presets -or $presets -isnot [ PSCustomObject ]) {
throw 'Registry presets must be an object'
}
$presetEntries = @ ( $presets . PSObject . Properties )
$priorityFor = {
param ( $Entry )
if ( $Entry . Value -is [ PSCustomObject ]) {
$priorityProperty = $Entry . Value . PSObject . Properties [ 'priority' ]
2026-08-10 10:22:31 -05:00
if ( $priorityProperty ) {
return Get-NormalizedPriority -Value $priorityProperty . Value
}
2026-07-21 18:40:15 +02:00
}
return 10
}
$sortedPresets = $presetEntries |
Where-Object { $_ . Value -is [ PSCustomObject ] } |
2026-08-10 10:22:31 -05:00
Where-Object {
$enabled = $_ . Value . PSObject . Properties [ 'enabled' ]
-not $enabled -or [ bool ] $enabled . Value
} |
Where-Object { $_ . Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @ { Expression = { & $priorityFor $_ } }, @ { Expression = { $_ . Name } } |
2026-03-13 15:09:14 -05:00
ForEach-Object { $_ . Name }
}
2026-07-21 18:40:15 +02:00
$registryParsed = $true
2026-03-13 15:09:14 -05:00
} catch {
2026-07-21 18:40:15 +02:00
$registryParsed = $false
2026-03-13 15:09:14 -05:00
}
}
2026-07-21 18:40:15 +02:00
if ( $registryParsed ) {
2026-03-13 15:09:14 -05:00
foreach ( $presetId in $sortedPresets ) {
$candidate = Join-Path $presetsDir " $presetId /templates/ $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
2026-08-10 10:22:31 -05:00
$candidate = Join-Path $presetsDir " $presetId / $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
2026-03-13 15:09:14 -05:00
}
} else {
# Fallback: alphabetical directory order
2026-07-21 18:40:15 +02:00
foreach ( $preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_ . Name -notlike '.*' } | Sort-Object Name ) {
2026-03-13 15:09:14 -05:00
$candidate = Join-Path $preset . FullName "templates/ $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
2026-08-10 10:22:31 -05:00
$candidate = Join-Path $preset . FullName " $TemplateName .md"
if ( Test-Path $candidate ) { return $candidate }
2026-03-13 15:09:14 -05:00
}
}
}
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if ( Test-Path $extDir ) {
2026-08-10 10:22:31 -05:00
foreach ( $extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir ) {
$candidate = Join-Path $extDir " $extensionId /templates/ $TemplateName .md"
if ( -not ( Test-Path $candidate )) {
$candidate = Join-Path $extDir " $extensionId / $TemplateName .md"
}
2026-03-13 15:09:14 -05:00
if ( Test-Path $candidate ) { return $candidate }
}
}
# Priority 4: Core templates
$core = Join-Path $base " $TemplateName .md"
if ( Test-Path $core ) { return $core }
return $null
}
2026-04-23 10:07:52 -05:00
# 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.
function Resolve-TemplateContent {
param (
[ Parameter ( Mandatory = $true )][ string ] $TemplateName ,
[ Parameter ( Mandatory = $true )][ string ] $RepoRoot
)
2026-08-10 10:22:31 -05:00
if ( $TemplateName -cnotmatch '^[a-z0-9-]+$' ) {
return $null
}
2026-04-23 10:07:52 -05:00
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
$layerPaths = @ ()
$layerStrategies = @ ()
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/ $TemplateName .md"
if ( Test-Path $override ) {
2026-08-10 10:22:31 -05:00
return [ System.IO.File ]:: ReadAllText (
$override ,
[ System.Text.Encoding ]:: UTF8
)
2026-04-23 10:07:52 -05:00
}
2026-08-10 10:22:31 -05:00
$effectiveBaseFound = $false
2026-04-23 10:07:52 -05:00
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if ( Test-Path $presetsDir ) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @ ()
2026-08-10 10:22:31 -05:00
$registryParsed = $false
2026-04-23 10:07:52 -05:00
if ( Test-Path $registryFile ) {
try {
2026-08-10 10:22:31 -05:00
$registryData = [ System.IO.File ]:: ReadAllText ( $registryFile , [ System.Text.Encoding ]:: UTF8 ) | ConvertFrom-Json
if ( $null -eq $registryData -or $registryData -isnot [ PSCustomObject ]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData . PSObject . Properties [ 'presets' ]
if ( $presetsProperty ) {
$presets = $presetsProperty . Value
if ( $null -eq $presets -or $presets -isnot [ PSCustomObject ]) {
throw 'Registry presets must be an object'
}
$presetEntries = @ ( $presets . PSObject . Properties )
$priorityFor = {
param ( $Entry )
if ( $Entry . Value -is [ PSCustomObject ]) {
$priorityProperty = $Entry . Value . PSObject . Properties [ 'priority' ]
if ( $priorityProperty ) {
return Get-NormalizedPriority -Value $priorityProperty . Value
}
}
return 10
}
$sortedPresets = $presetEntries |
Where-Object { $_ . Value -is [ PSCustomObject ] } |
Where-Object {
$enabled = $_ . Value . PSObject . Properties [ 'enabled' ]
-not $enabled -or [ bool ] $enabled . Value
} |
Where-Object { $_ . Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @ { Expression = { & $priorityFor $_ } }, @ { Expression = { $_ . Name } } |
2026-04-23 10:07:52 -05:00
ForEach-Object { $_ . Name }
}
2026-08-10 10:22:31 -05:00
$registryParsed = $true
2026-04-23 10:07:52 -05:00
} catch {
2026-08-10 10:22:31 -05:00
$registryParsed = $false
2026-04-23 10:07:52 -05:00
}
}
2026-08-10 10:22:31 -05:00
if ( -not $registryParsed ) {
$sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_ . Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object Name |
ForEach-Object { $_ . Name }
}
$pyCmd = @ ( Get-Python3Command )
foreach ( $presetId in $sortedPresets ) {
2026-04-23 10:07:52 -05:00
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
2026-08-10 10:22:31 -05:00
$manifestDeclared = $false
2026-04-23 10:07:52 -05:00
$manifest = Join-Path $presetsDir " $presetId /preset.yml"
2026-08-10 10:22:31 -05:00
if (( Test-Path $manifest ) -and -not $pyCmd ) {
throw "Python 3 and PyYAML are required to resolve preset template composition"
}
if ( Test-Path $manifest ) {
2026-04-23 10:07:52 -05:00
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ( $pyCmd . Count -gt 1 ) { $pyCmd [ 1 ..( $pyCmd . Count - 1 )] } else { @ () }
$pyStderrFile = [ System.IO.Path ]:: GetTempFileName ()
$stratResult = & $pyCmd [ 0 ] @pyArgs -c @"
import sys
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
2026-08-10 10:22:31 -05:00
sys.exit(2)
2026-04-23 10:07:52 -05:00
try:
2026-08-10 10:22:31 -05:00
with open(sys.argv[1], encoding='utf-8') as f:
2026-04-23 10:07:52 -05:00
data = yaml.safe_load(f)
2026-08-10 10:22:31 -05:00
if not isinstance(data, dict):
raise ValueError('manifest root must be a mapping')
if 'provides' not in data:
raise ValueError('manifest missing provides section')
provides = data['provides']
if not isinstance(provides, dict):
raise ValueError('manifest provides must be a mapping')
if 'templates' not in provides:
raise ValueError('manifest provides missing templates')
templates = provides['templates']
if not isinstance(templates, list):
raise ValueError('manifest templates must be a list')
if not templates:
raise ValueError('manifest must provide at least one template')
valid_types = ('template', 'command', 'script')
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
for t in templates:
if not isinstance(t, dict):
raise ValueError('manifest template entries must be mappings')
if 'type' not in t or 'name' not in t or 'file' not in t:
raise ValueError('manifest template entry missing type, name, or file')
for field in ('type', 'name', 'file'):
if not isinstance(t[field], str):
raise ValueError('manifest template ' + field + ' must be a string')
if t['type'] not in valid_types:
raise ValueError('invalid manifest template type')
strategy = t.get('strategy', 'replace')
if not isinstance(strategy, str):
raise ValueError('manifest template strategy must be a string')
strategy = strategy.lower()
if strategy not in valid_strategies:
raise ValueError('invalid manifest template strategy')
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
raise ValueError('invalid manifest script strategy')
for t in templates:
2026-04-23 10:07:52 -05:00
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
2026-08-10 10:22:31 -05:00
file_value = t.get('file', '')
strategy = t.get('strategy', 'replace')
print('found\t' + strategy + '\t' + file_value)
2026-04-23 10:07:52 -05:00
sys.exit(0)
2026-08-10 10:22:31 -05:00
print('absent\treplace\t')
except Exception as exc:
print(f'manifest_invalid: {exc}', file=sys.stderr)
sys.exit(3)
2026-04-23 10:07:52 -05:00
"@ $manifest $TemplateName 2 > $pyStderrFile
2026-08-10 10:22:31 -05:00
if ( $LASTEXITCODE -ne 0 ) {
if ( $LASTEXITCODE -eq 2 ) {
throw "PyYAML is required to resolve preset template composition"
}
throw "Invalid preset manifest $manifest "
}
2026-04-23 10:07:52 -05:00
if ( $stratResult ) {
2026-08-10 10:22:31 -05:00
$parts = $stratResult . Trim () -split " `t " , 3
$manifestDeclared = $parts [ 0 ] -eq 'found'
$strategy = $parts [ 1 ]. ToLowerInvariant ()
if ( $parts . Count -gt 2 -and $parts [ 2 ]) { $manifestFilePath = $parts [ 2 ] }
2026-04-23 10:07:52 -05:00
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
if ( $pyStderrFile ) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
2026-08-10 10:22:31 -05:00
throw
2026-04-23 10:07:52 -05:00
}
}
# Try manifest file path first, then convention path
$candidate = $null
if ( $manifestFilePath ) {
# Reject absolute paths and parent traversal
if ([ System.IO.Path ]:: IsPathRooted ( $manifestFilePath ) -or $manifestFilePath -match '\.\.[\\/]' ) {
$manifestFilePath = ''
}
}
if ( $manifestFilePath ) {
$mf = Join-Path $presetsDir " $presetId / $manifestFilePath "
if ( Test-Path $mf ) { $candidate = $mf }
}
2026-08-10 10:22:31 -05:00
if ( -not $candidate -and -not $manifestDeclared ) {
2026-04-23 10:07:52 -05:00
$cf = Join-Path $presetsDir " $presetId /templates/ $TemplateName .md"
if ( Test-Path $cf ) { $candidate = $cf }
2026-08-10 10:22:31 -05:00
if ( -not $candidate ) {
$cf = Join-Path $presetsDir " $presetId / $TemplateName .md"
if ( Test-Path $cf ) { $candidate = $cf }
}
2026-04-23 10:07:52 -05:00
}
if ( $candidate ) {
$layerPaths += $candidate
$layerStrategies += $strategy
2026-08-10 10:22:31 -05:00
if ( $strategy -eq 'replace' ) {
$effectiveBaseFound = $true
break
}
2026-04-23 10:07:52 -05:00
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
2026-08-10 10:22:31 -05:00
if ( -not $effectiveBaseFound -and ( Test-Path $extDir )) {
foreach ( $extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir ) {
$candidate = Join-Path $extDir " $extensionId /templates/ $TemplateName .md"
if ( -not ( Test-Path $candidate )) {
$candidate = Join-Path $extDir " $extensionId / $TemplateName .md"
}
2026-04-23 10:07:52 -05:00
if ( Test-Path $candidate ) {
$layerPaths += $candidate
$layerStrategies += 'replace'
2026-08-10 10:22:31 -05:00
$effectiveBaseFound = $true
break
2026-04-23 10:07:52 -05:00
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base " $TemplateName .md"
2026-08-10 10:22:31 -05:00
if ( -not $effectiveBaseFound -and ( Test-Path $core )) {
2026-04-23 10:07:52 -05:00
$layerPaths += $core
$layerStrategies += 'replace'
}
if ( $layerPaths . Count -eq 0 ) { return $null }
2026-05-26 13:18:34 -05:00
# If the top (highest-priority) layer is replace, it wins entirely --
2026-04-23 10:07:52 -05:00
# lower layers are irrelevant regardless of their strategies.
if ( $layerStrategies [ 0 ] -eq 'replace' ) {
2026-08-10 10:22:31 -05:00
return [ System.IO.File ]:: ReadAllText ( $layerPaths [ 0 ], [ System.Text.Encoding ]:: UTF8 )
2026-04-23 10:07:52 -05:00
}
# Check if any layer uses a non-replace strategy
$hasComposition = $false
foreach ( $s in $layerStrategies ) {
if ( $s -ne 'replace' ) { $hasComposition = $true ; break }
}
if ( -not $hasComposition ) {
2026-08-10 10:22:31 -05:00
return [ System.IO.File ]:: ReadAllText ( $layerPaths [ 0 ], [ System.Text.Encoding ]:: UTF8 )
2026-04-23 10:07:52 -05:00
}
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
$baseIdx = -1
for ( $i = 0 ; $i -lt $layerPaths . Count ; $i ++) {
if ( $layerStrategies [ $i ] -eq 'replace' ) {
$baseIdx = $i
break
}
}
2026-08-10 10:22:31 -05:00
if ( $baseIdx -lt 0 ) {
throw "Template ' $TemplateName ' has composing layers but no replace base"
}
2026-04-23 10:07:52 -05:00
2026-08-10 10:22:31 -05:00
$content = [ System.IO.File ]:: ReadAllText (
$layerPaths [ $baseIdx ],
[ System.Text.Encoding ]:: UTF8
)
2026-04-23 10:07:52 -05:00
for ( $i = $baseIdx - 1 ; $i -ge 0 ; $i --) {
$path = $layerPaths [ $i ]
$strat = $layerStrategies [ $i ]
2026-08-10 10:22:31 -05:00
$layerContent = [ System.IO.File ]:: ReadAllText (
$path ,
[ System.Text.Encoding ]:: UTF8
)
2026-04-23 10:07:52 -05:00
switch ( $strat ) {
'replace' { $content = $layerContent }
'prepend' { $content = " $layerContent `n`n $content " }
'append' { $content = " $content `n`n $layerContent " }
'wrap' {
if ( -not $layerContent . Contains ( '{CORE_TEMPLATE}' )) {
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
}
$content = $layerContent . Replace ( '{CORE_TEMPLATE}' , $content )
}
default { throw "Unknown strategy: $strat " }
}
}
return $content
2026-05-28 23:02:27 +08:00
}