#requires -Version 5.1 [CmdletBinding()] param( [ValidatePattern('^[a-z0-9][a-z0-9_-]{0,63}$')] [string] $Repository = 'tess', [string] $Destination, [switch] $Yes, [string] $ForgeOrigin = 'https://trnsys.cloud', [switch] $ConfigureOnly ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Invoke-SetupGit { param([string[]] $Arguments) & $script:SetupGit @Arguments if ($LASTEXITCODE -ne 0) { throw "Git failed (exit $LASTEXITCODE). Setup stopped; existing files were retained." } } function Read-SetupGit { param([string[]] $Arguments) # Config lookup uses exit 1 for a missing key, which is not an error. $result = @(& $script:SetupGit @Arguments) if ($LASTEXITCODE -eq 1) { return @() } if ($LASTEXITCODE -ne 0) { throw "Git inspection failed (exit $LASTEXITCODE). No safe continuation." } return $result } function Get-ForgeSettings { return [ordered]@{ provider = 'generic' oauthClientId = 'trnsys-forge-gcm' oauthAuthorizeEndpoint = '/' oauthTokenEndpoint = '/auth/git/token' oauthDeviceEndpoint = '/auth/git/device' oauthScopes = 'git' oauthUseClientAuthHeader = 'false' oauthAuthModes = 'devicecode' } } # Machine setup is independent of a checkout and never shares credentials. function Set-ForgeMachine { $git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue if (-not $git) { throw 'Install Git for Windows from https://gitforwindows.org/ with Git Credential Manager enabled, then reopen PowerShell and rerun setup.' } $script:SetupGit = $git.Source try { $null = Invoke-SetupGit @('credential-manager', '--version') } catch { throw 'Git Credential Manager is missing. Repair Git for Windows with Git Credential Manager enabled, then rerun setup.' } $origins = @('https://trnsys.cloud', 'https://preview.trnsys.cloud') $context = [IO.Path]::GetTempPath() foreach ($origin in $origins) { $helpers = @(Read-SetupGit @('-C', $context, 'config', '--get-urlmatch', 'credential.helper', $origin)) if (-not ($helpers | Where-Object { $_ -match '(^|[/\\])(?:git-credential-)?manager(?:\.exe)?["'']?(?:\s|$)' })) { throw "Git Credential Manager is not the configured helper for $origin. Repair/configure stock GCM, then rerun setup. Existing helpers were retained." } } foreach ($origin in $origins) { foreach ($setting in (Get-ForgeSettings).GetEnumerator()) { Invoke-SetupGit @('config', '--global', '--replace-all', "credential.$origin.$($setting.Key)", $setting.Value) } } # Temporary, proved preview transport; other sites keep their HTTP settings. Invoke-SetupGit @('config', '--global', '--replace-all', 'http.https://preview.trnsys.cloud.version', 'HTTP/1.1') foreach ($origin in $origins) { foreach ($setting in (Get-ForgeSettings).GetEnumerator()) { $effective = @(Read-SetupGit @('-C', $context, 'config', '--get-urlmatch', "credential.$($setting.Key)", $origin)) if ($effective.Count -ne 1 -or $effective[0] -cne $setting.Value) { throw "Another configuration overrides $($setting.Key) for $origin. Settings were saved; resolve the override and rerun." } } } Write-Host 'Git is ready for trnsys.cloud and preview.trnsys.cloud.' Write-Host 'Return to your repository page and copy its clone command.' Write-Host 'On first use of each site, approve the code shown by Git Credential Manager.' } function Get-MainGuard { # LF and no BOM: Git for Windows executes this with its bundled sh. return (@' #!/bin/sh # trnsys-forge-main-guard-v1 branch=$(git symbolic-ref --quiet --short HEAD) || { echo 'Create a working branch before committing: git switch -c your-name/change' >&2 exit 1 } if [ "$branch" = main ]; then echo 'Main is protected. Create a working branch: git switch -c your-name/change' >&2 exit 1 fi '@).Replace("`r`n", "`n") + "`n" } function Get-GuardPath { param([string] $Checkout) $custom = @(Read-SetupGit @('-C', $Checkout, 'config', '--get-all', 'core.hooksPath')) if ($custom.Count -gt 0) { throw 'This checkout has core.hooksPath configured. Setup will not replace or bypass your hooks. Ask the forge maintainer to integrate the main guard.' } $relative = @(Invoke-SetupGit @('-C', $Checkout, 'rev-parse', '--git-path', 'hooks/pre-commit'))[0] $path = if ([IO.Path]::IsPathRooted($relative)) { $relative } else { Join-Path $Checkout $relative } if (Test-Path -LiteralPath $path) { $entry = Get-Item -LiteralPath $path -Force if ($entry.PSIsContainer -or ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'The pre-commit hook is not a regular file. It was left untouched.' } if ([IO.File]::ReadAllText($path) -cne (Get-MainGuard)) { throw 'An existing pre-commit hook was left untouched. Ask the forge maintainer to integrate the main guard; setup will not overwrite it.' } } return $path } function Test-Checkout { param([string] $Path, [string] $Remote) if (-not (Test-Path -LiteralPath $Path)) { return $false } if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw 'Destination is a file. Choose another destination.' } if (-not (Test-Path -LiteralPath (Join-Path $Path '.git'))) { throw 'Destination already exists but is not a checkout. Choose a new folder; setup will not overwrite it.' } $prefix = @(Invoke-SetupGit @('-C', $Path, 'rev-parse', '--show-prefix')) -join '' if ($prefix.Length -ne 0) { throw 'Destination is not the checkout root.' } $origins = @(Read-SetupGit @('-C', $Path, 'config', '--get-all', 'remote.origin.url')) if ($origins.Count -ne 1 -or $origins[0] -cne $Remote) { throw "Destination does not have the expected origin ($Remote). It was left untouched." } $pushUrls = @(Read-SetupGit @('-C', $Path, 'config', '--get-all', 'remote.origin.pushurl')) if ($pushUrls.Count -gt 0 -and ($pushUrls.Count -ne 1 -or $pushUrls[0] -cne $Remote)) { throw 'Origin has a different push URL. It was left untouched.' } # Also reject URL rewriting: the literal config must not disguise another server. foreach ($operation in @(@('remote', 'get-url', '--all', 'origin'), @('remote', 'get-url', '--push', '--all', 'origin'))) { $effective = @(Invoke-SetupGit (@('-C', $Path) + $operation)) if ($effective.Count -ne 1 -or $effective[0] -cne $Remote) { throw 'Git URL rewriting changes this origin. Resolve that configuration before setup.' } } $null = Get-GuardPath $Path return $true } function Start-ForgeSetup { param([string] $Name, [string] $Checkout, [bool] $Confirmed, [string] $Origin = 'https://trnsys.cloud') # A separate HTTPS origin gives preview its own GCM configuration and # credential namespace. Never accept embedded credentials or URL components. if ($Origin -cnotmatch '^https://[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$') { throw 'ForgeOrigin must be an HTTPS hostname with no path, port, query, or credentials.' } $uri = [Uri] $Origin if ($uri.HostNameType -ne [UriHostNameType]::Dns -or $uri.Host -cne $Origin.Substring(8)) { throw 'ForgeOrigin must be a canonical HTTPS DNS hostname.' } $git = Get-Command git -CommandType Application -ErrorAction SilentlyContinue if (-not $git) { throw 'Install Git for Windows from https://gitforwindows.org/ with Git Credential Manager enabled, then reopen PowerShell and rerun setup.' } $script:SetupGit = $git.Source try { $gcmVersion = @(Invoke-SetupGit @('credential-manager', '--version')) } catch { throw 'Git Credential Manager is missing or cannot run. Repair Git for Windows with Git Credential Manager enabled, then reopen PowerShell. Setup has not changed your helpers.' } Write-Host "Git Credential Manager: $($gcmVersion -join ' ')" $remote = "$Origin/git/$Name" if ([string]::IsNullOrWhiteSpace($Checkout)) { $suggested = Join-Path ([Environment]::GetFolderPath('UserProfile')) $Name $entered = Read-Host "Checkout folder [$suggested] (Ctrl+C cancels)" $Checkout = if ([string]::IsNullOrWhiteSpace($entered)) { $suggested } else { $entered } } $Checkout = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Checkout) $exists = Test-Checkout $Checkout $remote # Evaluate configuration from the destination context (including local overrides). $context = if ($exists) { $Checkout } else { [IO.Path]::GetTempPath() } $helpers = @(Read-SetupGit @('-C', $context, 'config', '--get-urlmatch', 'credential.helper', $remote)) if (-not ($helpers | Where-Object { $_ -match '(^|[/\\])(?:git-credential-)?manager(?:\.exe)?["'']?(?:\s|$)' })) { throw 'GCM is installed but is not the configured helper for this origin. Repair/configure stock GCM first. Setup preserves your existing helpers and credential storage.' } $rewritten = @(Invoke-SetupGit @('-C', $context, 'ls-remote', '--get-url', $remote)) if ($rewritten.Count -ne 1 -or $rewritten[0] -cne $remote) { throw 'Git URL rewriting changes this origin. Resolve that configuration before setup.' } Write-Host "Repository: $remote" Write-Host "Folder: $Checkout" if (-not $Confirmed) { $answer = Read-Host 'Configure this forge and prepare this checkout? [y/N]' if ($answer -notmatch '^(y|yes)$') { Write-Host 'Cancelled. Nothing changed.'; return } } foreach ($setting in (Get-ForgeSettings).GetEnumerator()) { Invoke-SetupGit @('config', '--global', '--replace-all', "credential.$Origin.$($setting.Key)", $setting.Value) } foreach ($setting in (Get-ForgeSettings).GetEnumerator()) { $effective = @(Read-SetupGit @('-C', $context, 'config', '--get-urlmatch', "credential.$($setting.Key)", $remote)) if ($effective.Count -ne 1 -or $effective[0] -cne $setting.Value) { throw "Another Git configuration overrides $($setting.Key) for this repository. Host settings were saved, but no clone or hook change was made. Resolve that override and rerun." } } if (-not $exists) { Write-Host 'Git may show a device code. Open its verification page, sign in, and approve only the Git request you just started.' # Keep native streams attached so GCM can present the approval flow. Invoke-SetupGit @('-C', $context, 'clone', '--origin', 'origin', '--', $remote, $Checkout) $null = Test-Checkout $Checkout $remote } else { Write-Host 'Existing checkout recognized. No fetch, reset, switch, or cleanup performed.' } $hook = Get-GuardPath $Checkout if (-not (Test-Path -LiteralPath $hook)) { $null = [IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($hook)) # CreateNew prevents overwriting a hook added since inspection. $stream = [IO.File]::Open($hook, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write) try { $bytes = [Text.Encoding]::UTF8.GetBytes((Get-MainGuard)) $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Dispose() } } Write-Host '' Write-Host 'Ready. Your existing changes and hooks are preserved.' $quotedCheckout = $Checkout.Replace("'", "''") Write-Host "Enter the folder: Set-Location -LiteralPath '$quotedCheckout'" Write-Host 'Create a branch: git switch -c your-name/short-description' Write-Host 'After editing: git add ' Write-Host ' git commit -m "Describe your change"' Write-Host ' git push -u origin HEAD' Write-Host 'The local commit guard is a convenience; the forge enforces main protection.' Write-Host 'Do not keep developing a branch after it has been squash-merged. Preserve local work and start a fresh branch from updated main.' } # Dot-sourcing exposes functions for isolated tests without running setup. if ($MyInvocation.InvocationName -ne '.') { try { if ($ConfigureOnly) { Set-ForgeMachine } else { Start-ForgeSetup $Repository $Destination ([bool]$Yes) $ForgeOrigin } } catch { Write-Error $_ -ErrorAction Continue; exit 1 } }