common.ps1 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. #!/usr/bin/env pwsh
  2. # Common PowerShell functions analogous to common.sh
  3. # Find repository root by searching upward for .specify directory
  4. # This is the primary marker for spec-kit projects
  5. function Find-SpecifyRoot {
  6. param([string]$StartDir = (Get-Location).Path)
  7. # Normalize to absolute path to prevent issues with relative paths
  8. # Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
  9. $resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
  10. $current = if ($resolved) { $resolved.Path } else { $null }
  11. if (-not $current) { return $null }
  12. while ($true) {
  13. if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
  14. return $current
  15. }
  16. $parent = Split-Path $current -Parent
  17. if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
  18. return $null
  19. }
  20. $current = $parent
  21. }
  22. }
  23. # Get repository root, prioritizing .specify directory over git
  24. # This prevents using a parent git repo when spec-kit is initialized in a subdirectory
  25. function Get-RepoRoot {
  26. # First, look for .specify directory (spec-kit's own marker)
  27. $specifyRoot = Find-SpecifyRoot
  28. if ($specifyRoot) {
  29. return $specifyRoot
  30. }
  31. # Fallback to git if no .specify found
  32. try {
  33. $result = git rev-parse --show-toplevel 2>$null
  34. if ($LASTEXITCODE -eq 0) {
  35. return $result
  36. }
  37. } catch {
  38. # Git command failed
  39. }
  40. # Final fallback to script location for non-git repos
  41. # Use -LiteralPath to handle paths with wildcard characters
  42. return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
  43. }
  44. function Get-CurrentBranch {
  45. # First check if SPECIFY_FEATURE environment variable is set
  46. if ($env:SPECIFY_FEATURE) {
  47. return $env:SPECIFY_FEATURE
  48. }
  49. # Then check git if available at the spec-kit root (not parent)
  50. $repoRoot = Get-RepoRoot
  51. if (Test-HasGit) {
  52. try {
  53. $result = git -C $repoRoot rev-parse --abbrev-ref HEAD 2>$null
  54. if ($LASTEXITCODE -eq 0) {
  55. return $result
  56. }
  57. } catch {
  58. # Git command failed
  59. }
  60. }
  61. # For non-git repos, try to find the latest feature directory
  62. $specsDir = Join-Path $repoRoot "specs"
  63. if (Test-Path $specsDir) {
  64. $latestFeature = ""
  65. $highest = 0
  66. $latestTimestamp = ""
  67. Get-ChildItem -Path $specsDir -Directory | ForEach-Object {
  68. if ($_.Name -match '^(\d{8}-\d{6})-') {
  69. # Timestamp-based branch: compare lexicographically
  70. $ts = $matches[1]
  71. if ($ts -gt $latestTimestamp) {
  72. $latestTimestamp = $ts
  73. $latestFeature = $_.Name
  74. }
  75. } elseif ($_.Name -match '^(\d{3,})-') {
  76. $num = [long]$matches[1]
  77. if ($num -gt $highest) {
  78. $highest = $num
  79. # Only update if no timestamp branch found yet
  80. if (-not $latestTimestamp) {
  81. $latestFeature = $_.Name
  82. }
  83. }
  84. }
  85. }
  86. if ($latestFeature) {
  87. return $latestFeature
  88. }
  89. }
  90. # Final fallback
  91. return "main"
  92. }
  93. # Check if we have git available at the spec-kit root level
  94. # Returns true only if git is installed and the repo root is inside a git work tree
  95. # Handles both regular repos (.git directory) and worktrees/submodules (.git file)
  96. function Test-HasGit {
  97. # First check if git command is available (before calling Get-RepoRoot which may use git)
  98. if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
  99. return $false
  100. }
  101. $repoRoot = Get-RepoRoot
  102. # Check if .git exists (directory or file for worktrees/submodules)
  103. # Use -LiteralPath to handle paths with wildcard characters
  104. if (-not (Test-Path -LiteralPath (Join-Path $repoRoot ".git"))) {
  105. return $false
  106. }
  107. # Verify it's actually a valid git work tree
  108. try {
  109. $null = git -C $repoRoot rev-parse --is-inside-work-tree 2>$null
  110. return ($LASTEXITCODE -eq 0)
  111. } catch {
  112. return $false
  113. }
  114. }
  115. # Strip a single optional path segment (e.g. gitflow "feat/004-name" -> "004-name").
  116. # Only when the full name is exactly two slash-free segments; otherwise returns the raw name.
  117. function Get-SpecKitEffectiveBranchName {
  118. param([string]$Branch)
  119. if ($Branch -match '^([^/]+)/([^/]+)$') {
  120. return $Matches[2]
  121. }
  122. return $Branch
  123. }
  124. function Test-FeatureBranch {
  125. param(
  126. [string]$Branch,
  127. [bool]$HasGit = $true
  128. )
  129. # For non-git repos, we can't enforce branch naming but still provide output
  130. if (-not $HasGit) {
  131. Write-Warning "[specify] Warning: Git repository not detected; skipped branch validation"
  132. return $true
  133. }
  134. $raw = $Branch
  135. $Branch = Get-SpecKitEffectiveBranchName $raw
  136. # Accept sequential prefix (3+ digits) but exclude malformed timestamps
  137. # Malformed: 7-or-8 digit date + 6-digit time with no trailing slug (e.g. "2026031-143022" or "20260319-143022")
  138. $hasMalformedTimestamp = ($Branch -match '^[0-9]{7}-[0-9]{6}-') -or ($Branch -match '^(?:\d{7}|\d{8})-\d{6}$')
  139. $isSequential = ($Branch -match '^[0-9]{3,}-') -and (-not $hasMalformedTimestamp)
  140. if (-not $isSequential -and $Branch -notmatch '^\d{8}-\d{6}-') {
  141. [Console]::Error.WriteLine("ERROR: Not on a feature branch. Current branch: $raw")
  142. [Console]::Error.WriteLine("Feature branches should be named like: 001-feature-name, 1234-feature-name, or 20260319-143022-feature-name")
  143. return $false
  144. }
  145. return $true
  146. }
  147. # True when .specify/feature.json pins an existing feature directory that matches the
  148. # active FEATURE_DIR from Get-FeaturePathsEnv (so /speckit.plan can skip git branch pattern checks).
  149. function Test-FeatureJsonMatchesFeatureDir {
  150. param(
  151. [Parameter(Mandatory = $true)][string]$RepoRoot,
  152. [Parameter(Mandatory = $true)][string]$ActiveFeatureDir
  153. )
  154. $featureJson = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
  155. if (-not (Test-Path -LiteralPath $featureJson -PathType Leaf)) {
  156. return $false
  157. }
  158. try {
  159. $raw = Get-Content -LiteralPath $featureJson -Raw
  160. $cfg = $raw | ConvertFrom-Json
  161. } catch {
  162. return $false
  163. }
  164. $fd = $cfg.feature_directory
  165. if ([string]::IsNullOrWhiteSpace([string]$fd)) {
  166. return $false
  167. }
  168. if (-not [System.IO.Path]::IsPathRooted($fd)) {
  169. $fd = Join-Path $RepoRoot $fd
  170. }
  171. if (-not (Test-Path -LiteralPath $fd -PathType Container)) {
  172. return $false
  173. }
  174. # Resolve both paths to canonical absolute form. Prefer Resolve-Path (follows
  175. # symlinks and is the canonical PS way); fall back to [Path]::GetFullPath when
  176. # Resolve-Path can't produce a value. Mirrors the pattern used by Find-SpecifyRoot.
  177. $resolvedJson = Resolve-Path -LiteralPath $fd -ErrorAction SilentlyContinue
  178. if ($resolvedJson) {
  179. $normJson = $resolvedJson.Path
  180. } else {
  181. $normJson = [System.IO.Path]::GetFullPath($fd)
  182. }
  183. $resolvedActive = Resolve-Path -LiteralPath $ActiveFeatureDir -ErrorAction SilentlyContinue
  184. if ($resolvedActive) {
  185. $normActive = $resolvedActive.Path
  186. } else {
  187. $normActive = [System.IO.Path]::GetFullPath($ActiveFeatureDir)
  188. }
  189. # Use case-insensitive compare only on Windows; POSIX filesystems are case-sensitive.
  190. # PowerShell 5.1 is Windows-only and does not define $IsWindows, so treat its
  191. # absence as "we're on Windows".
  192. if ($null -ne $IsWindows) {
  193. $onWindows = $IsWindows
  194. } else {
  195. $onWindows = $true
  196. }
  197. if ($onWindows) {
  198. $comparison = [System.StringComparison]::OrdinalIgnoreCase
  199. } else {
  200. $comparison = [System.StringComparison]::Ordinal
  201. }
  202. return [string]::Equals($normJson, $normActive, $comparison)
  203. }
  204. # Resolve specs/<feature-dir> by numeric/timestamp prefix (mirrors scripts/bash/common.sh find_feature_dir_by_prefix).
  205. function Find-FeatureDirByPrefix {
  206. param(
  207. [Parameter(Mandatory = $true)][string]$RepoRoot,
  208. [Parameter(Mandatory = $true)][string]$Branch
  209. )
  210. $specsDir = Join-Path $RepoRoot 'specs'
  211. $branchName = Get-SpecKitEffectiveBranchName $Branch
  212. $prefix = $null
  213. if ($branchName -match '^(\d{8}-\d{6})-') {
  214. $prefix = $Matches[1]
  215. } elseif ($branchName -match '^(\d{3,})-') {
  216. $prefix = $Matches[1]
  217. } else {
  218. return (Join-Path $specsDir $branchName)
  219. }
  220. $dirMatches = @()
  221. if (Test-Path -LiteralPath $specsDir -PathType Container) {
  222. $dirMatches = @(Get-ChildItem -LiteralPath $specsDir -Filter "$prefix-*" -Directory -ErrorAction SilentlyContinue)
  223. }
  224. if ($dirMatches.Count -eq 0) {
  225. return (Join-Path $specsDir $branchName)
  226. }
  227. if ($dirMatches.Count -eq 1) {
  228. return $dirMatches[0].FullName
  229. }
  230. $names = ($dirMatches | ForEach-Object { $_.Name }) -join ' '
  231. [Console]::Error.WriteLine("ERROR: Multiple spec directories found with prefix '$prefix': $names")
  232. [Console]::Error.WriteLine('Please ensure only one spec directory exists per prefix.')
  233. return $null
  234. }
  235. # Branch-based prefix resolution; mirrors bash get_feature_paths failure (stderr + exit 1).
  236. function Get-FeatureDirFromBranchPrefixOrExit {
  237. param(
  238. [Parameter(Mandatory = $true)][string]$RepoRoot,
  239. [Parameter(Mandatory = $true)][string]$CurrentBranch
  240. )
  241. $resolved = Find-FeatureDirByPrefix -RepoRoot $RepoRoot -Branch $CurrentBranch
  242. if ($null -eq $resolved) {
  243. [Console]::Error.WriteLine('ERROR: Failed to resolve feature directory')
  244. exit 1
  245. }
  246. return $resolved
  247. }
  248. function Get-FeaturePathsEnv {
  249. $repoRoot = Get-RepoRoot
  250. $currentBranch = Get-CurrentBranch
  251. $hasGit = Test-HasGit
  252. # Resolve feature directory. Priority:
  253. # 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
  254. # 2. .specify/feature.json "feature_directory" key (persisted by /speckit.specify)
  255. # 3. Branch-name-based prefix lookup (same as scripts/bash/common.sh)
  256. $featureJson = Join-Path $repoRoot '.specify/feature.json'
  257. if ($env:SPECIFY_FEATURE_DIRECTORY) {
  258. $featureDir = $env:SPECIFY_FEATURE_DIRECTORY
  259. # Normalize relative paths to absolute under repo root
  260. if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
  261. $featureDir = Join-Path $repoRoot $featureDir
  262. }
  263. } elseif (Test-Path $featureJson) {
  264. $featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
  265. try {
  266. $featureConfig = $featureJsonRaw | ConvertFrom-Json
  267. } catch {
  268. [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
  269. exit 1
  270. }
  271. if ($featureConfig.feature_directory) {
  272. $featureDir = $featureConfig.feature_directory
  273. # Normalize relative paths to absolute under repo root
  274. if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
  275. $featureDir = Join-Path $repoRoot $featureDir
  276. }
  277. } else {
  278. $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
  279. }
  280. } else {
  281. $featureDir = Get-FeatureDirFromBranchPrefixOrExit -RepoRoot $repoRoot -CurrentBranch $currentBranch
  282. }
  283. [PSCustomObject]@{
  284. REPO_ROOT = $repoRoot
  285. CURRENT_BRANCH = $currentBranch
  286. HAS_GIT = $hasGit
  287. FEATURE_DIR = $featureDir
  288. FEATURE_SPEC = Join-Path $featureDir 'spec.md'
  289. IMPL_PLAN = Join-Path $featureDir 'plan.md'
  290. TASKS = Join-Path $featureDir 'tasks.md'
  291. RESEARCH = Join-Path $featureDir 'research.md'
  292. DATA_MODEL = Join-Path $featureDir 'data-model.md'
  293. QUICKSTART = Join-Path $featureDir 'quickstart.md'
  294. CONTRACTS_DIR = Join-Path $featureDir 'contracts'
  295. }
  296. }
  297. function Test-FileExists {
  298. param([string]$Path, [string]$Description)
  299. if (Test-Path -Path $Path -PathType Leaf) {
  300. Write-Output " ✓ $Description"
  301. return $true
  302. } else {
  303. Write-Output " ✗ $Description"
  304. return $false
  305. }
  306. }
  307. function Test-DirHasFiles {
  308. param([string]$Path, [string]$Description)
  309. if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Where-Object { -not $_.PSIsContainer } | Select-Object -First 1)) {
  310. Write-Output " ✓ $Description"
  311. return $true
  312. } else {
  313. Write-Output " ✗ $Description"
  314. return $false
  315. }
  316. }
  317. # Find a usable Python 3 executable (python3, python, or py -3).
  318. # Returns the command/arguments as an array, or $null if none found.
  319. function Get-Python3Command {
  320. if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
  321. if (Get-Command python -ErrorAction SilentlyContinue) {
  322. $ver = & python --version 2>&1
  323. if ($ver -match 'Python 3') { return @('python') }
  324. }
  325. if (Get-Command py -ErrorAction SilentlyContinue) {
  326. $ver = & py -3 --version 2>&1
  327. if ($ver -match 'Python 3') { return @('py', '-3') }
  328. }
  329. return $null
  330. }
  331. # Resolve a template name to a file path using the priority stack:
  332. # 1. .specify/templates/overrides/
  333. # 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
  334. # 3. .specify/extensions/<ext-id>/templates/
  335. # 4. .specify/templates/ (core)
  336. function Resolve-Template {
  337. param(
  338. [Parameter(Mandatory=$true)][string]$TemplateName,
  339. [Parameter(Mandatory=$true)][string]$RepoRoot
  340. )
  341. $base = Join-Path $RepoRoot '.specify/templates'
  342. # Priority 1: Project overrides
  343. $override = Join-Path $base "overrides/$TemplateName.md"
  344. if (Test-Path $override) { return $override }
  345. # Priority 2: Installed presets (sorted by priority from .registry)
  346. $presetsDir = Join-Path $RepoRoot '.specify/presets'
  347. if (Test-Path $presetsDir) {
  348. $registryFile = Join-Path $presetsDir '.registry'
  349. $sortedPresets = @()
  350. if (Test-Path $registryFile) {
  351. try {
  352. $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
  353. $presets = $registryData.presets
  354. if ($presets) {
  355. $sortedPresets = $presets.PSObject.Properties |
  356. Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
  357. Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
  358. ForEach-Object { $_.Name }
  359. }
  360. } catch {
  361. # Fallback: alphabetical directory order
  362. $sortedPresets = @()
  363. }
  364. }
  365. if ($sortedPresets.Count -gt 0) {
  366. foreach ($presetId in $sortedPresets) {
  367. $candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
  368. if (Test-Path $candidate) { return $candidate }
  369. }
  370. } else {
  371. # Fallback: alphabetical directory order
  372. foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
  373. $candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
  374. if (Test-Path $candidate) { return $candidate }
  375. }
  376. }
  377. }
  378. # Priority 3: Extension-provided templates
  379. $extDir = Join-Path $RepoRoot '.specify/extensions'
  380. if (Test-Path $extDir) {
  381. foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
  382. $candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
  383. if (Test-Path $candidate) { return $candidate }
  384. }
  385. }
  386. # Priority 4: Core templates
  387. $core = Join-Path $base "$TemplateName.md"
  388. if (Test-Path $core) { return $core }
  389. return $null
  390. }
  391. # Resolve a template name to composed content using composition strategies.
  392. # Reads strategy metadata from preset manifests and composes content
  393. # from multiple layers using prepend, append, or wrap strategies.
  394. function Resolve-TemplateContent {
  395. param(
  396. [Parameter(Mandatory=$true)][string]$TemplateName,
  397. [Parameter(Mandatory=$true)][string]$RepoRoot
  398. )
  399. $base = Join-Path $RepoRoot '.specify/templates'
  400. # Collect all layers (highest priority first)
  401. $layerPaths = @()
  402. $layerStrategies = @()
  403. # Priority 1: Project overrides (always "replace")
  404. $override = Join-Path $base "overrides/$TemplateName.md"
  405. if (Test-Path $override) {
  406. $layerPaths += $override
  407. $layerStrategies += 'replace'
  408. }
  409. # Priority 2: Installed presets (sorted by priority from .registry)
  410. $presetsDir = Join-Path $RepoRoot '.specify/presets'
  411. if (Test-Path $presetsDir) {
  412. $registryFile = Join-Path $presetsDir '.registry'
  413. $sortedPresets = @()
  414. if (Test-Path $registryFile) {
  415. try {
  416. $registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
  417. $presets = $registryData.presets
  418. if ($presets) {
  419. $sortedPresets = $presets.PSObject.Properties |
  420. Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
  421. Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
  422. ForEach-Object { $_.Name }
  423. }
  424. } catch {
  425. $sortedPresets = @()
  426. }
  427. }
  428. if ($sortedPresets.Count -gt 0) {
  429. $pyCmd = Get-Python3Command
  430. if (-not $pyCmd) {
  431. # Check if any preset has strategy fields that would be ignored
  432. foreach ($pid in $sortedPresets) {
  433. $mf = Join-Path $presetsDir "$pid/preset.yml"
  434. if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
  435. Write-Warning "No Python 3 found; preset composition strategies will be ignored"
  436. break
  437. }
  438. }
  439. }
  440. $yamlWarned = $false
  441. foreach ($presetId in $sortedPresets) {
  442. # Read strategy and file path from preset manifest
  443. $strategy = 'replace'
  444. $manifestFilePath = ''
  445. $manifest = Join-Path $presetsDir "$presetId/preset.yml"
  446. if ((Test-Path $manifest) -and $pyCmd) {
  447. try {
  448. # Use Python to parse YAML manifest for strategy and file path
  449. $pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
  450. $pyStderrFile = [System.IO.Path]::GetTempFileName()
  451. $stratResult = & $pyCmd[0] @pyArgs -c @"
  452. import sys
  453. try:
  454. import yaml
  455. except ImportError:
  456. print('yaml_missing', file=sys.stderr)
  457. print('replace\t')
  458. sys.exit(0)
  459. try:
  460. with open(sys.argv[1]) as f:
  461. data = yaml.safe_load(f)
  462. for t in data.get('provides', {}).get('templates', []):
  463. if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
  464. print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
  465. sys.exit(0)
  466. print('replace\t')
  467. except Exception:
  468. print('replace\t')
  469. "@ $manifest $TemplateName 2>$pyStderrFile
  470. if ($stratResult) {
  471. $parts = $stratResult.Trim() -split "`t", 2
  472. $strategy = $parts[0].ToLowerInvariant()
  473. if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
  474. }
  475. if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
  476. Write-Warning "PyYAML not available; composition strategies may be ignored"
  477. $yamlWarned = $true
  478. }
  479. Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
  480. } catch {
  481. $strategy = 'replace'
  482. if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
  483. }
  484. }
  485. # Try manifest file path first, then convention path
  486. $candidate = $null
  487. if ($manifestFilePath) {
  488. # Reject absolute paths and parent traversal
  489. if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
  490. $manifestFilePath = ''
  491. }
  492. }
  493. if ($manifestFilePath) {
  494. $mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
  495. if (Test-Path $mf) { $candidate = $mf }
  496. }
  497. if (-not $candidate) {
  498. $cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
  499. if (Test-Path $cf) { $candidate = $cf }
  500. }
  501. if ($candidate) {
  502. $layerPaths += $candidate
  503. $layerStrategies += $strategy
  504. }
  505. }
  506. } else {
  507. # Fallback: alphabetical directory order (no registry or parse failure)
  508. foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
  509. $candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
  510. if (Test-Path $candidate) {
  511. $layerPaths += $candidate
  512. $layerStrategies += 'replace'
  513. }
  514. }
  515. }
  516. }
  517. # Priority 3: Extension-provided templates (always "replace")
  518. $extDir = Join-Path $RepoRoot '.specify/extensions'
  519. if (Test-Path $extDir) {
  520. foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
  521. $candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
  522. if (Test-Path $candidate) {
  523. $layerPaths += $candidate
  524. $layerStrategies += 'replace'
  525. }
  526. }
  527. }
  528. # Priority 4: Core templates (always "replace")
  529. $core = Join-Path $base "$TemplateName.md"
  530. if (Test-Path $core) {
  531. $layerPaths += $core
  532. $layerStrategies += 'replace'
  533. }
  534. if ($layerPaths.Count -eq 0) { return $null }
  535. # If the top (highest-priority) layer is replace, it wins entirely —
  536. # lower layers are irrelevant regardless of their strategies.
  537. if ($layerStrategies[0] -eq 'replace') {
  538. return (Get-Content $layerPaths[0] -Raw)
  539. }
  540. # Check if any layer uses a non-replace strategy
  541. $hasComposition = $false
  542. foreach ($s in $layerStrategies) {
  543. if ($s -ne 'replace') { $hasComposition = $true; break }
  544. }
  545. if (-not $hasComposition) {
  546. return (Get-Content $layerPaths[0] -Raw)
  547. }
  548. # Find the effective base: scan from highest priority (index 0) downward
  549. # to find the nearest replace layer. Only compose layers above that base.
  550. $baseIdx = -1
  551. for ($i = 0; $i -lt $layerPaths.Count; $i++) {
  552. if ($layerStrategies[$i] -eq 'replace') {
  553. $baseIdx = $i
  554. break
  555. }
  556. }
  557. if ($baseIdx -lt 0) { return $null }
  558. $content = Get-Content $layerPaths[$baseIdx] -Raw
  559. for ($i = $baseIdx - 1; $i -ge 0; $i--) {
  560. $path = $layerPaths[$i]
  561. $strat = $layerStrategies[$i]
  562. $layerContent = Get-Content $path -Raw
  563. switch ($strat) {
  564. 'replace' { $content = $layerContent }
  565. 'prepend' { $content = "$layerContent`n`n$content" }
  566. 'append' { $content = "$content`n`n$layerContent" }
  567. 'wrap' {
  568. if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
  569. throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
  570. }
  571. $content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
  572. }
  573. default { throw "Unknown strategy: $strat" }
  574. }
  575. }
  576. return $content
  577. }