Templates/customer-repo/init.ps1
|
# ============================================================================ # MANAGED FILE - DO NOT EDIT IN THE CUSTOMER WORKSPACE # Source: Templates\customer-repo\init.ps1 inside the # NKDAgility.AzureDevOps.AutomationTools module. # This file is overwritten from that template on every run. Edit it there. # ============================================================================ <# .SYNOPSIS Initialises this customer workspace: materialises every declared capability, refreshes the framework-owned files, and loads the session. Run at the start of every session, and dot-source it at the top of runbooks: . .\init.ps1 .DESCRIPTION capabilities.json declares which nkdAgility engines this workspace uses. For each one, init.ps1: 1. Resolves the engine clone (see below), cloning it if missing and fast-forward pulling it if clean. 2. COPIES system\<Module> out of that clone into .system\<Module> and records what it copied in .system\<Module>\.source.json. 3. Scaffolds the engine's Templates\customer-repo\** into this workspace, only-if-missing, then overwrites the files listed in that template's .managed file. 4. Renders the engine's Agents\CAPABILITY.md into the workspace's agent guidance files. 5. Dot-sources <name>\init.ps1 if the engine shipped one. Finally it imports the automation tools module and calls Initialize-AutomationWorkspace against this folder. An engine clone is resolved in order: 1. $env:AZDO_ENGINE_<NAME> (e.g. AZDO_ENGINE_GOVERNANCE) 2. $env:AZDO_AUTOMATION_TOOLS (the 'automation' capability only) 3. 'enginePaths.<name>' or 'toolsPath' in workspace.local.json (gitignored) 4. %USERPROFILE%\source\repos\<repo-name> .system\ is GENERATED and read-only. Never edit it: the next run overwrites it, and init.ps1 stops with an error if it notices a hand-edit rather than discarding the work silently. Change the engine in its clone and re-run - the copy takes uncommitted edits, so that is the normal way to test an engine change against a real workspace. .PARAMETER NoSync Skip the git clone/pull step (offline work, or when iterating on local engine changes). Everything is still materialised from whatever the local clones currently hold. #> [CmdletBinding()] param( [switch]$NoSync ) $ErrorActionPreference = 'Stop' $workspaceRoot = $PSScriptRoot # --- Local machine overrides ------------------------------------------------ $localFile = Join-Path $workspaceRoot 'workspace.local.json' $local = if (Test-Path -LiteralPath $localFile) { Get-Content -LiteralPath $localFile -Raw | ConvertFrom-Json } else { $null } # --- Which capabilities does this workspace use? ---------------------------- # The registry is the WORKSPACE's, not any engine's: that is what lets a workspace # take on governance without the automation tools knowing governance exists. $capabilitiesFile = Join-Path $workspaceRoot 'capabilities.json' $capabilities = if (Test-Path -LiteralPath $capabilitiesFile) { @((Get-Content -LiteralPath $capabilitiesFile -Raw | ConvertFrom-Json).capabilities) } else { # A workspace scaffolded before capabilities.json existed still works. @([pscustomobject]@{ name = 'automation' module = 'NKDAgility.AzureDevOps.AutomationTools' repo = 'https://github.com/nkdAgility/azure-devops-automation-tools.git' }) } # --- Helpers ---------------------------------------------------------------- $sameContent = { param($a, $b) ((Get-Content -LiteralPath $a -Raw) -replace "`r`n", "`n") -eq ((Get-Content -LiteralPath $b -Raw) -replace "`r`n", "`n") } # Hash of a folder's contents: relative path + file hash for every file, sorted so the # result is stable. Used to notice a hand-edited .system\ before it gets overwritten. $treeHash = { param([string]$Root) if (-not (Test-Path -LiteralPath $Root)) { return $null } $rootLength = (Get-Item -LiteralPath $Root).FullName.Length + 1 $lines = Get-ChildItem -LiteralPath $Root -Recurse -File -Force | Where-Object { $_.Name -ne '.source.json' } | ForEach-Object { "$($_.FullName.Substring($rootLength))|$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } | Sort-Object $stream = [System.IO.MemoryStream]::new([System.Text.Encoding]::UTF8.GetBytes(($lines -join "`n"))) (Get-FileHash -InputStream $stream -Algorithm SHA256).Hash } $resolveEnginePath = { param($capability) $repoName = [System.IO.Path]::GetFileNameWithoutExtension(($capability.repo -split '/')[-1]) $envName = "AZDO_ENGINE_$($capability.name.ToUpperInvariant() -replace '[^A-Z0-9]', '_')" $candidates = @(@( [Environment]::GetEnvironmentVariable($envName) if ($capability.name -eq 'automation') { $env:AZDO_AUTOMATION_TOOLS } if ($local -and $local.PSObject.Properties['enginePaths'] -and $local.enginePaths.PSObject.Properties[$capability.name]) { $local.enginePaths.$($capability.name) } if ($capability.name -eq 'automation' -and $local -and $local.PSObject.Properties['toolsPath']) { $local.toolsPath } (Join-Path $env:USERPROFILE (Join-Path 'source\repos' $repoName)) ) | Where-Object { $_ }) # The @() around the whole pipeline matters: piping to Where-Object unwraps a # single survivor to a bare string, and [0] on a string is its first CHARACTER. # With no workspace.local.json - the normal case on a fresh clone - only the # default candidate survives, and the engine path resolved to 'C'. @{ Path = $candidates[0]; RepoName = $repoName } } $systemRoot = Join-Path $workspaceRoot '.system' $selfUpdated = $false $loadedCapabilities = [System.Collections.Generic.List[hashtable]]::new() # --- Per capability: sync, materialise, scaffold ---------------------------- foreach ($capability in $capabilities) { $resolved = & $resolveEnginePath $capability $enginePath = $resolved.Path # 1. Clone or fast-forward pull. A dirty clone is left alone: the local edits are # almost always the point, and clobbering them would be worse than being stale. if (-not $NoSync) { if (-not (Test-Path -LiteralPath $enginePath)) { Write-Host "==> Cloning $($capability.repo)" -ForegroundColor Cyan git clone $capability.repo $enginePath if ($LASTEXITCODE -ne 0) { Write-Warning "Clone failed for $($capability.repo); continuing." } } elseif (git -C $enginePath status --porcelain) { Write-Warning "$($resolved.RepoName) has local changes; skipping pull." } else { git -C $enginePath pull --ff-only 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { Write-Warning "$($resolved.RepoName) pull failed (offline?); continuing with the existing clone." } } } $moduleSource = Join-Path $enginePath (Join-Path 'system' $capability.module) if (-not (Test-Path -LiteralPath $moduleSource)) { throw "Capability '$($capability.name)': module not found at '$moduleSource'. Expected a clone of $($capability.repo) at '$enginePath'." } $modulePath = Join-Path $systemRoot $capability.module # 2. Materialise. Refuse to discard a hand-edit silently. $recordPath = Join-Path $modulePath '.source.json' if ((Test-Path -LiteralPath $modulePath) -and (Test-Path -LiteralPath $recordPath)) { $record = Get-Content -LiteralPath $recordPath -Raw | ConvertFrom-Json if ($record.treeHash -and (& $treeHash $modulePath) -ne $record.treeHash) { throw ".system\$($capability.module) has been modified since it was copied. It is generated and must not be edited - move your change into '$moduleSource' and re-run. To discard the local edit, delete '$modulePath' and re-run." } } $sourceSha = (git -C $enginePath rev-parse HEAD 2>$null) $sourceDirty = [bool](git -C $enginePath status --porcelain 2>$null) if (Test-Path -LiteralPath $modulePath) { Get-ChildItem -LiteralPath $modulePath -Recurse -File -Force | ForEach-Object { $_.IsReadOnly = $false } Remove-Item -LiteralPath $modulePath -Recurse -Force } New-Item -Path $systemRoot -ItemType Directory -Force | Out-Null Copy-Item -LiteralPath $moduleSource -Destination $modulePath -Recurse Write-Host "==> $($capability.name): copied $($capability.module) into .system\" -ForegroundColor Cyan @{ capability = $capability.name module = $capability.module source = $enginePath sha = if ($sourceSha) { $sourceSha } else { 'unknown' } dirty = $sourceDirty treeHash = (& $treeHash $modulePath) copiedAt = (Get-Date).ToString('o') } | ConvertTo-Json | Set-Content -LiteralPath $recordPath if ($sourceDirty) { Write-Warning "$($resolved.RepoName) has uncommitted changes; .system\ holds them. Commit them before relying on this run being reproducible." } # Read-only so an accidental save in the editor fails loudly instead of being lost. Get-ChildItem -LiteralPath $modulePath -Recurse -File -Force | ForEach-Object { $_.IsReadOnly = $true } # 3. Scaffold this capability's slice of the workspace. Every engine ships the same # shape - Templates\customer-repo\** relative to the workspace root - so the # automation tools land at the root and governance lands under governance\. $templateRoot = Join-Path $modulePath 'Templates\customer-repo' if (Test-Path -LiteralPath $templateRoot) { $managedList = @() $managedFile = Join-Path $templateRoot '.managed' if (Test-Path -LiteralPath $managedFile) { $managedList = @(Get-Content -LiteralPath $managedFile | Where-Object { $_.Trim() -and -not $_.Trim().StartsWith('#') } | ForEach-Object { $_.Trim() -replace '/', '\' }) } # A real dot-file in the template would hide it from the engine repo, so # .gitignore ships under a neutral name and is renamed on the way out. $renameMap = @{ 'gitignore.template' = '.gitignore' } # Source material for rendered agent guidance; it stays in the module. $doNotScaffold = @('CLAUDE.managed.md', '.managed') $templateRootLength = (Get-Item -LiteralPath $templateRoot).FullName.Length + 1 foreach ($template in (Get-ChildItem -LiteralPath $templateRoot -Recurse -File -Force)) { $relative = $template.FullName.Substring($templateRootLength) if ($relative -in $doNotScaffold) { continue } $isManaged = $relative -in $managedList if ($renameMap.ContainsKey($relative)) { $relative = $renameMap[$relative] } $target = Join-Path $workspaceRoot $relative if (Test-Path -LiteralPath $target) { # Seeds are the workspace's own once copied; only managed files are refreshed. if (-not $isManaged) { continue } if (& $sameContent $template.FullName $target) { continue } } New-Item -Path (Split-Path -Parent $target) -ItemType Directory -Force | Out-Null if (Test-Path -LiteralPath $target) { (Get-Item -LiteralPath $target).IsReadOnly = $false } Copy-Item -LiteralPath $template.FullName -Destination $target -Force # The template is read from .system\, which is read-only; the workspace's copy # is the workspace's own, so clear the attribute Copy-Item carried across. (Get-Item -LiteralPath $target).IsReadOnly = $false $verb = if ($isManaged) { 'Updated' } else { 'Created' } Write-Host " $verb $relative" -ForegroundColor $(if ($isManaged) { 'Cyan' } else { 'Green' }) if ($relative -eq 'init.ps1') { $selfUpdated = $true } } } $loadedCapabilities.Add(@{ Name = $capability.name Module = $capability.module ModulePath = $modulePath InitScript = Join-Path $workspaceRoot (Join-Path $capability.name 'init.ps1') }) } # This running copy is now the stale one, so hand over to the new file. The # env guard stops a bad template turning the handover into a loop. if ($selfUpdated -and -not $env:AZDO_INIT_RELOADED) { $env:AZDO_INIT_RELOADED = '1' try { . $PSCommandPath -NoSync:$NoSync } finally { Remove-Item Env:\AZDO_INIT_RELOADED -ErrorAction SilentlyContinue } return } # --- Standard folders ------------------------------------------------------- foreach ($folder in 'data', 'exports', 'migrations', 'output', 'secrets') { New-Item -Path (Join-Path $workspaceRoot $folder) -ItemType Directory -Force | Out-Null } # --- Scaffold missing local files from their examples ---------------------- # Every committed '<name>.example.<ext>' has a gitignored '<name>.<ext>' sibling # that each machine owns (secrets/secrets.json, and any per-migration equivalent). # Create the missing ones from the example so a fresh clone has the right shape, # with the placeholders left in - never a real value. $examples = Get-ChildItem -LiteralPath $workspaceRoot -Recurse -File -Filter '*.example.*' -Force | Where-Object { $_.FullName -notmatch '\\(\.git|\.system|output)\\' } $needsValues = [System.Collections.Generic.List[string]]::new() foreach ($example in $examples) { $target = Join-Path $example.DirectoryName ($example.Name -replace '\.example(\.[^.]+)$', '$1') $relative = if ($target.StartsWith($workspaceRoot, [StringComparison]::OrdinalIgnoreCase)) { $target.Substring($workspaceRoot.Length).TrimStart('\', '/') } else { $target } if (-not (Test-Path -LiteralPath $target)) { Copy-Item -LiteralPath $example.FullName -Destination $target Write-Host "==> Created $relative from $($example.Name)" -ForegroundColor Yellow } # Unedited '<placeholder>' markers mean the file has no real values yet. if ((Get-Content -LiteralPath $target -Raw) -match '"<[^">\r\n]+>"') { $needsValues.Add($relative) } } if ($needsValues.Count) { Write-Warning "Placeholders still to fill in: $($needsValues -join ', ')" } # --- Render agent guidance -------------------------------------------------- # Each engine ships Agents\CAPABILITY.md describing how to work that capability. # It is RENDERED into the workspace's agent files rather than referenced, because # the result is committed: a fresh clone then has full guidance before init.ps1 has # ever run, and Copilot has no import mechanism at all. Files are co-owned - only # the marked block is rewritten, so anything the workspace wrote above it survives. $blockBody = @() $blockBody += (Get-Content -LiteralPath (Join-Path $systemRoot 'NKDAgility.AzureDevOps.AutomationTools\Templates\customer-repo\CLAUDE.managed.md') -Raw -ErrorAction SilentlyContinue) foreach ($loaded in $loadedCapabilities) { $capabilityDoc = Join-Path $loaded.ModulePath 'Agents\CAPABILITY.md' if (Test-Path -LiteralPath $capabilityDoc) { $blockBody += (Get-Content -LiteralPath $capabilityDoc -Raw) } } $blockBody = (($blockBody | Where-Object { $_ }) -join "`n`n").TrimEnd() $blockStart = '<!-- BEGIN managed: nkdagility -->' $blockEnd = '<!-- END managed: nkdagility -->' $block = "$blockStart`n$blockBody`n$blockEnd" $renderTargets = @( @{ Path = 'CLAUDE.md'; Skeleton = $null } @{ Path = 'AGENTS.md'; Skeleton = "# Agent guide`n`nSee CLAUDE.md for this workspace's own notes. The block below is generated.`n" } @{ Path = '.github\copilot-instructions.md'; Skeleton = "# Copilot instructions`n`nSee CLAUDE.md for this workspace's own notes. The block below is generated.`n" } ) foreach ($target in $renderTargets) { $file = Join-Path $workspaceRoot $target.Path if (-not (Test-Path -LiteralPath $file)) { if (-not $target.Skeleton) { continue } New-Item -Path (Split-Path -Parent $file) -ItemType Directory -Force | Out-Null Set-Content -LiteralPath $file -Value $target.Skeleton } $current = Get-Content -LiteralPath $file -Raw # Spliced by index rather than regex: the block spans newlines and can contain '$' # and other regex-replacement metacharacters, both of which silently corrupt a # -replace here. $startAt = $current.IndexOf($blockStart) $endAt = $current.IndexOf($blockEnd) $updated = if ($startAt -ge 0 -and $endAt -gt $startAt) { $current.Substring(0, $startAt) + $block + $current.Substring($endAt + $blockEnd.Length) } else { $current.TrimEnd() + "`n`n" + $block + "`n" } if (($updated -replace "`r`n", "`n") -ne ($current -replace "`r`n", "`n")) { Set-Content -LiteralPath $file -Value $updated -NoNewline Write-Host "==> Rendered agent guidance into $($target.Path)" -ForegroundColor Cyan } } # --- Import the automation tools and initialise the workspace --------------- # From .system\, never from a clone: the workspace runs the copy it recorded. $automation = $loadedCapabilities | Where-Object { $_.Module -eq 'NKDAgility.AzureDevOps.AutomationTools' } | Select-Object -First 1 if (-not $automation) { throw "capabilities.json must include the 'NKDAgility.AzureDevOps.AutomationTools' module: it owns the workspace itself." } Import-Module $automation.ModulePath -Force Initialize-AutomationWorkspace -Path $workspaceRoot | Out-Null # --- Export the workspace secrets as environment variables ------------------ # One secrets file serves every capability: the migration tools bind them into .NET # config, and a governance manifest.yaml names one as its accessToken. -NoClobber means # a CI-provided secret or a deliberate per-shell override always wins over the file. $secretsPath = (Get-AutomationWorkspace).SecretsPath if (Test-Path -LiteralPath $secretsPath) { Set-AutomationSecrets -SecretsPath $secretsPath -NoClobber | Out-Null } # --- Load each capability --------------------------------------------------- foreach ($loaded in $loadedCapabilities) { if (Test-Path -LiteralPath $loaded.InitScript) { . $loaded.InitScript -WorkspaceRoot $workspaceRoot } } |