scripts/init/_utilities.ps1
|
# Shared utilities for specrew-init.ps1 (extracted via Proposal 108 Slice 1) # # Pure leaf — no dependencies on other init/ files. Behavior identical to the # original inline definitions in scripts/specrew-init.ps1 (extracted unchanged). # # Functions exported (all internal-only; called by specrew-init.ps1 and other init/*.ps1): # - Get-NativeExitCode read $LASTEXITCODE safely # - ConvertTo-YamlBoolean render bool as YAML "true"/"false" # - Test-ConsoleInputRedirected detect stdin redirection # - Write-Step cyan "==> ..." step header # - Invoke-NativeCommand run native exe; throw on non-zero # - Invoke-NativeCommandForOutput run native exe; capture stdout + exit code # - Invoke-NativeCommandWithClosedInput run a native command with immediate stdin EOF # - Invoke-WithNativeCommandEncoding force UTF-8 for specify on Windows # - Add-Action append @{Step,Outcome} to action list # - Ensure-DirectoryExists mkdir -p with PreviewOnly support # - Get-SpecrewExecutionLayout resolve module-vs-clone mode + TemplateRoot # - Write-MissingUtf8File idempotent UTF-8-no-BOM file write Set-StrictMode -Version Latest function Get-NativeExitCode { if (Get-Variable -Name LASTEXITCODE -Scope Global -ErrorAction SilentlyContinue) { return $global:LASTEXITCODE } return 0 } function ConvertTo-YamlBoolean { param( [Parameter(Mandatory = $true)] [bool]$Value ) if ($Value) { return 'true' } return 'false' } function Test-ConsoleInputRedirected { try { return [Console]::IsInputRedirected } catch { return $true } } function Write-Step { param( [Parameter(Mandatory = $true)] [string]$Message ) Write-Host ("==> {0}" -f $Message) -ForegroundColor Cyan } function Invoke-NativeCommand { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$ArgumentList, [Parameter(Mandatory = $true)] [string]$WorkingDirectory ) Push-Location $WorkingDirectory try { Invoke-WithNativeCommandEncoding -FilePath $FilePath -ScriptBlock { & $FilePath @ArgumentList if ((Get-NativeExitCode) -ne 0) { throw ("Command failed: {0} {1}" -f $FilePath, ($ArgumentList -join ' ')) } } } finally { Pop-Location } } function Invoke-NativeCommandForOutput { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$ArgumentList, [Parameter(Mandatory = $true)] [string]$WorkingDirectory ) Push-Location $WorkingDirectory try { $output = Invoke-WithNativeCommandEncoding -FilePath $FilePath -ScriptBlock { @(& $FilePath @ArgumentList 2>&1) } return [pscustomobject]@{ ExitCode = Get-NativeExitCode Output = @($output | ForEach-Object { [string]$_ }) } } finally { Pop-Location } } function Invoke-NativeCommandWithClosedInput { <# .SYNOPSIS Runs a native command with redirected stdin closed immediately. .DESCRIPTION Some third-party CLIs decide whether to prompt from process.stdin.isTTY even when their documented non-interactive flag is present. Capturing their output while inheriting a live console can therefore hide the prompt and wait forever. This launcher gives the child an immediate EOF and captures init-time output for the caller to classify or display. PowerShell resolves npm shims to an ExternalScript (for example squad.ps1) on Windows. ProcessStartInfo cannot launch that script directly with UseShellExecute=false, so script shims are hosted by the current PowerShell executable while applications are launched directly. #> param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$ArgumentList, [Parameter(Mandatory = $true)] [string]$WorkingDirectory, [Parameter(Mandatory = $true)] [ValidateRange(1, 7200)] [int]$TimeoutSeconds ) $resolvedCommand = @(Get-Command -Name $FilePath -CommandType Application, ExternalScript -ErrorAction Stop)[0] $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.UseShellExecute = $false $startInfo.RedirectStandardInput = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.WorkingDirectory = $WorkingDirectory if ($resolvedCommand.CommandType -eq [System.Management.Automation.CommandTypes]::ExternalScript) { $startInfo.FileName = [Environment]::ProcessPath foreach ($prefixArgument in @('-NoProfile', '-NonInteractive', '-File', [string]$resolvedCommand.Source)) { [void]$startInfo.ArgumentList.Add($prefixArgument) } } else { $startInfo.FileName = [string]$resolvedCommand.Source } foreach ($argument in $ArgumentList) { [void]$startInfo.ArgumentList.Add($argument) } $process = [System.Diagnostics.Process]::new() $process.StartInfo = $startInfo $outputDrainTimeoutMilliseconds = 10000 try { [void]$process.Start() $stdoutTask = $process.StandardOutput.ReadToEndAsync() $stderrTask = $process.StandardError.ReadToEndAsync() $process.StandardInput.Close() if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { $terminationError = $null try { if (-not $process.HasExited) { $process.Kill($true) } } catch { $terminationError = [string]$_.Exception.Message } $terminationVerified = $false try { $terminationVerified = $process.WaitForExit(10000) } catch { if ([string]::IsNullOrWhiteSpace($terminationError)) { $terminationError = [string]$_.Exception.Message } } # Diagnostic collection is bounded too. A descendant retaining an inherited output handle must not # turn the timeout path into a second indefinite wait after the process-tree kill request. $diagnosticOutput = [System.Collections.Generic.List[string]]::new() $diagnosticsComplete = $false try { $diagnosticsComplete = [System.Threading.Tasks.Task]::WaitAll( [System.Threading.Tasks.Task[]]@($stdoutTask, $stderrTask), $outputDrainTimeoutMilliseconds ) if ($diagnosticsComplete) { foreach ($streamText in @($stdoutTask.GetAwaiter().GetResult(), $stderrTask.GetAwaiter().GetResult())) { if (-not [string]::IsNullOrEmpty($streamText)) { foreach ($line in @($streamText -split '\r?\n')) { if (-not [string]::IsNullOrEmpty($line)) { $diagnosticOutput.Add($line) | Out-Null } } } } } } catch { $diagnosticsComplete = $false } $terminationState = if ($terminationVerified) { 'verified' } else { 'unverified' } $diagnosticState = if ($diagnosticsComplete) { 'complete' } else { 'incomplete' } $message = "native-command-timeout:file=$FilePath`:timeout_seconds=$TimeoutSeconds`:termination=$terminationState`:diagnostics=$diagnosticState" if (-not [string]::IsNullOrWhiteSpace($terminationError)) { $message += (':termination_error=' + ($terminationError -replace '[\r\n]+', ' ')) } if ($diagnosticOutput.Count -gt 0) { $boundedDiagnostic = (@($diagnosticOutput | Select-Object -First 3) -join ' | ') if ($boundedDiagnostic.Length -gt 500) { $boundedDiagnostic = $boundedDiagnostic.Substring(0, 500) } $message += (':output=' + $boundedDiagnostic) } throw [System.TimeoutException]::new($message) } # The root process can exit while a descendant still owns an inherited output handle. Bound the normal # drain just like the timeout path so a nominally successful child cannot create a secondary hang. $outputDrainComplete = $false try { $outputDrainComplete = [System.Threading.Tasks.Task]::WaitAll( [System.Threading.Tasks.Task[]]@($stdoutTask, $stderrTask), $outputDrainTimeoutMilliseconds ) } catch { throw [System.InvalidOperationException]::new( "native-command-output-drain-failed:file=$FilePath`:process_exit=verified", $_.Exception ) } if (-not $outputDrainComplete) { throw [System.TimeoutException]::new( "native-command-output-drain-timeout:file=$FilePath`:drain_timeout_ms=$outputDrainTimeoutMilliseconds`:process_exit=verified`:diagnostics=incomplete" ) } $output = [System.Collections.Generic.List[string]]::new() foreach ($streamText in @($stdoutTask.GetAwaiter().GetResult(), $stderrTask.GetAwaiter().GetResult())) { if (-not [string]::IsNullOrEmpty($streamText)) { foreach ($line in @($streamText -split '\r?\n')) { if (-not [string]::IsNullOrEmpty($line)) { $output.Add($line) | Out-Null } } } } return [pscustomobject]@{ ExitCode = $process.ExitCode Output = @($output) } } finally { $process.Dispose() } } function Invoke-WithNativeCommandEncoding { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [scriptblock]$ScriptBlock ) $shouldForceUtf8 = $IsWindows -and $FilePath -eq 'specify' if (-not $shouldForceUtf8) { return & $ScriptBlock } $utf8 = [System.Text.UTF8Encoding]::new($false) $previousOutputEncoding = $null $previousInputEncoding = $null $previousPipelineEncoding = $OutputEncoding $previousPythonUtf8 = [Environment]::GetEnvironmentVariable('PYTHONUTF8', 'Process') $previousPythonIoEncoding = [Environment]::GetEnvironmentVariable('PYTHONIOENCODING', 'Process') try { $previousOutputEncoding = [Console]::OutputEncoding $previousInputEncoding = [Console]::InputEncoding } catch { $shouldForceUtf8 = $false } if (-not $shouldForceUtf8) { return & $ScriptBlock } try { [Console]::OutputEncoding = $utf8 [Console]::InputEncoding = $utf8 $script:OutputEncoding = $utf8 [Environment]::SetEnvironmentVariable('PYTHONUTF8', '1', 'Process') [Environment]::SetEnvironmentVariable('PYTHONIOENCODING', 'utf-8', 'Process') return & $ScriptBlock } finally { [Console]::OutputEncoding = $previousOutputEncoding [Console]::InputEncoding = $previousInputEncoding $script:OutputEncoding = $previousPipelineEncoding [Environment]::SetEnvironmentVariable('PYTHONUTF8', $previousPythonUtf8, 'Process') [Environment]::SetEnvironmentVariable('PYTHONIOENCODING', $previousPythonIoEncoding, 'Process') } } function Add-Action { param( [AllowEmptyCollection()] [Parameter(Mandatory = $true)] [System.Collections.ArrayList]$Actions, [Parameter(Mandatory = $true)] [string]$Step, [Parameter(Mandatory = $true)] [string]$Outcome ) $null = $Actions.Add([pscustomobject]@{ Step = $Step Outcome = $Outcome }) } function Ensure-DirectoryExists { param( [Parameter(Mandatory = $true)] [string]$Path, [Parameter(Mandatory = $true)] [switch]$PreviewOnly ) if (Test-Path -LiteralPath $Path) { return } if ($PreviewOnly) { return } New-Item -ItemType Directory -Path $Path -Force | Out-Null } function Get-SpecrewExecutionLayout { # Walk up from this file's location until we find the Specrew distribution root # (marked by Specrew.psd1). This is robust against file relocation — when this # function lived in scripts/specrew-init.ps1 the root was 1 level up; after the # Proposal 108 Slice 1 extraction to scripts/init/_utilities.ps1 the root is # 2 levels up. The marker-file walk works in both cases + any future relocation. $distributionRoot = $PSScriptRoot for ($i = 0; $i -lt 5; $i++) { if (Test-Path -LiteralPath (Join-Path $distributionRoot 'Specrew.psd1') -PathType Leaf) { break } $parent = Split-Path -Parent $distributionRoot if ([string]::IsNullOrWhiteSpace($parent) -or $parent -eq $distributionRoot) { # Reached filesystem root without finding the marker — fall back to legacy 1-up resolution $distributionRoot = Split-Path -Parent $PSScriptRoot break } $distributionRoot = $parent } $templateRoot = Join-Path -Path $distributionRoot -ChildPath 'templates' $isModuleLayout = $env:SPECREW_INVOKED_FROM_MODULE -eq '1' return [pscustomobject]@{ RootPath = $distributionRoot Mode = $(if ($isModuleLayout) { 'module' } else { 'clone' }) TemplateRoot = $(if (Test-Path -LiteralPath $templateRoot -PathType Container) { $templateRoot } else { $null }) } } function Write-MissingUtf8File { param( [Parameter(Mandatory = $true)] [string]$Path, [AllowEmptyString()] [Parameter(Mandatory = $true)] [string]$Content, [Parameter(Mandatory = $true)] [switch]$PreviewOnly ) if (Test-Path -LiteralPath $Path) { return } if ($PreviewOnly) { return } $parent = Split-Path -Parent $Path if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Path $parent -Force | Out-Null } [System.IO.File]::WriteAllText($Path, $Content, [System.Text.UTF8Encoding]::new($false)) } |