From b54f53e174e9c3db9e162346264c3117dd9bc665 Mon Sep 17 00:00:00 2001 From: "Robert J. Good" Date: Fri, 23 Oct 2026 13:00:17 -0700 Subject: [PATCH 1/2] branding mapping --- README.md | 13 ++ Update-Branding.Tests.ps1 | 74 +++++++++++ Update-Branding.ps1 | 252 ++++++++++++++++++++++++++++++++++++++ branding.map.json | 86 +++++++++++++ branding.sample.json | 12 ++ 5 files changed, 437 insertions(+) create mode 100644 Update-Branding.Tests.ps1 create mode 100644 Update-Branding.ps1 create mode 100644 branding.map.json create mode 100644 branding.sample.json diff --git a/README.md b/README.md index f2c37b7..5a1b949 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,19 @@ dotnet ef database update --project .\src\Infrastructure.SqlServer\Infrastructur For detailed alternatives and troubleshooting, see the Authentication section below. +## Rebrand a clone for your organization +After cloning, `Update-Branding.ps1` replaces the original organization and Quick-start product branding across the whole repo (source, solution files, GitHub Actions, Azure DevOps, IaC, docs). It is a dry run unless `-Apply` is given, never replaces the repo's NuGet package dependencies, Entra scopes, user-secrets keys or the `AgentFramework` EF context/database names, and regenerates the `UserSecretsId` values. + +```powershell +# Preview (no changes) +pwsh -File ./Update-Branding.ps1 -NewCompany "Contoso" -NewProduct "Contoso Assist" -Namespace "Contoso.Assist" + +# Apply on a clean working tree, then review the diff +pwsh -File ./Update-Branding.ps1 -NewCompany "Contoso" -NewProduct "Contoso Assist" -Namespace "Contoso.Assist" -Apply +``` + +See `branding.sample.json` (use with `-ConfigFile`) for all values. Rules are in `branding.map.json`. Afterwards, run the Entra setup steps above for the new product. + --- # Install Prerequisites diff --git a/Update-Branding.Tests.ps1 b/Update-Branding.Tests.ps1 new file mode 100644 index 0000000..4e846d4 --- /dev/null +++ b/Update-Branding.Tests.ps1 @@ -0,0 +1,74 @@ +BeforeAll { + $script:Script = Join-Path $PSScriptRoot 'Update-Branding.ps1' + $script:Map = Join-Path $PSScriptRoot 'branding.map.json' + + function New-Fixture { + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("brand-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path "$root\src\App", "$root\.github\workflows" -Force | Out-Null + Set-Content "$root\Goodtocode.AgentFramework.Web.slnx" '' + Set-Content "$root\.github\workflows\gtc-agent-standalone-iac.yml" 'name: gtc-agent-standalone-iac' + Set-Content "$root\src\App\App.csproj" @' + +Goodtocode.AgentFramework.App +bf5b92ab-f3fe-4af6-b299-583240397247 + + + + +'@ + Set-Content "$root\src\App\Page.razor" 'Quick-start for Microsoft Agent Framework

GoodToCode | developers@goodtocode.com

' + Set-Content "$root\src\App\Auth.cs" 'var s = "access_as_user"; // Goodtocode.AgentFramework.App' + return $root + } +} + +Describe 'Update-Branding Values' { + BeforeEach { $script:Root = New-Fixture } + AfterEach { Remove-Item $script:Root -Recurse -Force -ErrorAction SilentlyContinue } + + It 'is a dry run by default' { + & $Script -Path $Root -NewCompany Contoso -NewProduct 'Contoso Assist' -Namespace Contoso.Assist | Out-Null + Get-Content "$Root\src\App\Page.razor" -Raw | Should -Match 'GoodToCode' + Test-Path "$Root\Goodtocode.AgentFramework.Web.slnx" | Should -BeTrue + } + + It 'rebrands content and renames files while preserving dependencies and contracts' { + & $Script -Path $Root -NewCompany Contoso -NewProduct 'Contoso Assist' -Namespace Contoso.Assist -Apply -Force | Out-Null + $LASTEXITCODE | Should -Be 0 + Test-Path "$Root\Contoso.Assist.Web.slnx" | Should -BeTrue + Test-Path "$Root\.github\workflows\contoso-agent-standalone-iac.yml" | Should -BeTrue + $csproj = Get-Content "$Root\src\App\App.csproj" -Raw + $csproj | Should -Match 'Contoso\.Assist\.App' + $csproj | Should -Match 'Goodtocode\.Mediator' + $csproj | Should -Match 'Goodtocode\.Agents\.Playbook' + $csproj | Should -Not -Match 'bf5b92ab-f3fe-4af6-b299-583240397247' + Get-Content "$Root\src\App\Page.razor" -Raw | Should -Match 'Contoso Assist.*Contoso \| developers@contoso\.com' + Get-Content "$Root\src\App\Auth.cs" -Raw | Should -Match 'access_as_user' + } + + It 'keeps UserSecretsId values with -KeepUserSecretsIds' { + & $Script -Path $Root -NewCompany Contoso -NewProduct 'Contoso Assist' -Namespace Contoso.Assist -Apply -Force -KeepUserSecretsIds | Out-Null + Get-Content "$Root\src\App\App.csproj" -Raw | Should -Match 'bf5b92ab-f3fe-4af6-b299-583240397247' + } +} + +Describe 'Update-Branding token targets' { + BeforeEach { $script:Root = New-Fixture } + AfterEach { Remove-Item $script:Root -Recurse -Force -ErrorAction SilentlyContinue } + + It 'DotnetNew injects tokens and leaves the namespace prefix for sourceName' { + & $Script -Path "$Root\src" -Target DotnetNew -Apply -FailOnLeftover | Out-Null + $LASTEXITCODE | Should -Be 0 + Get-Content "$Root\src\App\Page.razor" -Raw | Should -Match '__PRODUCT_NAME__.*__COMPANY_NAME__' + $csproj = Get-Content "$Root\src\App\App.csproj" -Raw + $csproj | Should -Match 'Goodtocode\.AgentFramework\.App' + $csproj | Should -Match '__USER_SECRETS_ID_API__' + } + + It 'Vsix only injects guid tokens and neutral placeholders' { + & $Script -Path "$Root\src" -Target Vsix -Apply -FailOnLeftover | Out-Null + $LASTEXITCODE | Should -Be 0 + Get-Content "$Root\src\App\App.csproj" -Raw | Should -Match '\$guid1\$' + Get-Content "$Root\src\App\Page.razor" -Raw | Should -Match 'GoodToCode' + } +} diff --git a/Update-Branding.ps1 b/Update-Branding.ps1 new file mode 100644 index 0000000..fe88f10 --- /dev/null +++ b/Update-Branding.ps1 @@ -0,0 +1,252 @@ +<# +.SYNOPSIS +Rebrands this repository (or a staged template copy) using branding.map.json. + +.DESCRIPTION +-Target Values : replaces GoodToCode branding with the supplied customer values (clone scenario). +-Target DotnetNew : injects dotnet new tokens into a staged copy of src (used by the template repo). +-Target Vsix : injects VSIX tokens / neutral placeholders into a staged copy of src (used by the template repo). + +Dry run by default. Use -Apply to write changes. Nothing is committed. + +.EXAMPLE +pwsh ./Update-Branding.ps1 -NewCompany Contoso -NewProduct "Contoso Assist" -Namespace Contoso.Assist +pwsh ./Update-Branding.ps1 -NewCompany Contoso -NewProduct "Contoso Assist" -Namespace Contoso.Assist -Apply +#> +[CmdletBinding()] +param( + [string] $Path = $PSScriptRoot, + [ValidateSet('Values', 'DotnetNew', 'Vsix')] + [string] $Target = 'Values', + [string] $MapFile = (Join-Path $PSScriptRoot 'branding.map.json'), + [string] $ConfigFile, + + [string] $NewCompany, + [string] $NewProduct, + [string] $Namespace, + [string] $CompanySlug, + [string] $CompanyShort, + [string] $Domain, + [string] $ContactEmail, + [string] $RepoOwner, + [string] $RepoName, + [string] $TenantHost = 'your-tenant-name.ciamlogin.com', + + [switch] $Apply, + [switch] $Force, + [switch] $FailOnLeftover, + [switch] $KeepUserSecretsIds, + [switch] $IncludeLicense +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$Path = (Resolve-Path $Path).Path +$map = Get-Content -Path $MapFile -Raw | ConvertFrom-Json +$isValues = $Target -eq 'Values' + +function ConvertTo-Slug([string] $Text) { + return ($Text.ToLowerInvariant() -replace '[^a-z0-9]+', '-').Trim('-') +} + +# ---------- Resolve customer values (Values target only) ---------- +$values = @{} +if ($isValues) { + if ($ConfigFile) { + $cfg = Get-Content -Path $ConfigFile -Raw | ConvertFrom-Json + foreach ($name in 'NewCompany', 'NewProduct', 'Namespace', 'CompanySlug', 'CompanyShort', 'Domain', 'ContactEmail', 'RepoOwner', 'RepoName', 'TenantHost') { + if (-not $PSBoundParameters.ContainsKey($name) -and $cfg.PSObject.Properties.Name -contains $name) { + Set-Variable -Name $name -Value $cfg.$name + } + } + } + foreach ($required in 'NewCompany', 'NewProduct', 'Namespace') { + if ([string]::IsNullOrWhiteSpace((Get-Variable -Name $required -ValueOnly))) { + throw "-$required is required for -Target Values (or supply it in -ConfigFile)." + } + } + if (-not $CompanySlug) { $CompanySlug = (ConvertTo-Slug $NewCompany).Replace('-', '') } + if (-not $CompanyShort) { $CompanyShort = $CompanySlug } + if (-not $Domain) { $Domain = "$CompanySlug.com" } + if (-not $ContactEmail) { $ContactEmail = "developers@$Domain" } + if (-not $RepoOwner) { $RepoOwner = $CompanySlug } + if (-not $RepoName) { $RepoName = ConvertTo-Slug $NewProduct } + + $values = @{ + NewCompany = $NewCompany; NewProduct = $NewProduct; Namespace = $Namespace + CompanySlug = $CompanySlug; CompanyShort = $CompanyShort; Domain = $Domain + ContactEmail = $ContactEmail; RepoOwner = $RepoOwner; RepoName = $RepoName; TenantHost = $TenantHost + } + + if ($Apply -and -not $Force -and (Get-Command git -ErrorAction SilentlyContinue)) { + $dirty = git -C $Path status --porcelain 2>$null + if ($LASTEXITCODE -eq 0 -and $dirty) { + throw 'Working tree has uncommitted changes. Commit or stash them, or pass -Force.' + } + } +} + +# ---------- Build the effective rule list for this target ---------- +$newGuids = @{} +$rules = foreach ($rule in $map.rules) { + $replacement = $null + if ($isValues) { + if ($rule.value -eq '@guid') { + if ($KeepUserSecretsIds) { continue } + $replacement = [guid]::NewGuid().ToString() + } + else { + $replacement = $rule.value + foreach ($key in $values.Keys) { $replacement = $replacement.Replace("{$key}", $values[$key]) } + } + } + else { + $replacement = $rule.tokens.$Target + if ($null -eq $replacement) { continue } + } + $pattern = if ($rule.kind -eq 'regex') { $rule.find } else { [regex]::Escape($rule.find) } + [pscustomobject]@{ + Id = $rule.id + Regex = [regex]::new($pattern) + Replacement = $replacement.Replace('$', '$$') + } +} + +$protect = @($map.protect) +if (-not $isValues) { $protect += @($map.protectForTokens) } +$protect = $protect | Sort-Object Length -Descending +$maskOpen = [string][char]0xE000 +$maskClose = [string][char]0xE001 + +function Protect-Text([string] $Text) { + for ($i = 0; $i -lt $protect.Count; $i++) { $Text = $Text.Replace($protect[$i], "$maskOpen$i$maskClose") } + return $Text +} +function Restore-Text([string] $Text) { + for ($i = 0; $i -lt $protect.Count; $i++) { $Text = $Text.Replace("$maskOpen$i$maskClose", $protect[$i]) } + return $Text +} + +$leftoverRegexes = @($map.leftover.$Target | ForEach-Object { [regex]::new($_) }) + +# ---------- Collect files ---------- +$excludeDirs = @($map.excludeDirectories) +$excludeFiles = @($map.excludeFiles) +if ($IncludeLicense) { $excludeFiles = $excludeFiles | Where-Object { $_ -ne 'LICENSE' } } +$textExt = @($map.textExtensions) + +$rootLen = $Path.TrimEnd('\', '/').Length + 1 +$files = Get-ChildItem -Path $Path -Recurse -File -Force | Where-Object { + $relative = $_.FullName.Substring($rootLen) + $segments = $relative -split '[\\/]' + foreach ($segment in $segments[0..($segments.Count - 2)]) { + if ($segments.Count -gt 1 -and $excludeDirs -contains $segment) { return $false } + } + foreach ($pattern in $excludeFiles) { if ($_.Name -like $pattern) { return $false } } + return ($textExt -contains $_.Extension.ToLowerInvariant()) -or ($textExt -contains $_.Name.ToLowerInvariant()) +} + +# ---------- Content pass ---------- +$utf8 = [System.Text.UTF8Encoding]::new($false) +$bom = [byte[]](0xEF, 0xBB, 0xBF) +$changed = [System.Collections.Generic.List[object]]::new() +$leftovers = [System.Collections.Generic.List[object]]::new() +$guidMap = @{} + +foreach ($file in $files) { + $bytes = [System.IO.File]::ReadAllBytes($file.FullName) + if ($bytes.Length -eq 0) { continue } + if (($bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) -or ($bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF)) { + Write-Warning "Skipping UTF-16 file: $($file.FullName)" + continue + } + $hasBom = $bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF + $offset = if ($hasBom) { 3 } else { 0 } + $original = $utf8.GetString($bytes, $offset, $bytes.Length - $offset) + + $text = Protect-Text $original + $hits = [System.Collections.Generic.List[string]]::new() + foreach ($rule in $rules) { + $count = $rule.Regex.Matches($text).Count + if ($count -eq 0) { continue } + $replacement = $rule.Replacement + if ($isValues -and $replacement -match '^[0-9a-f]{8}-') { + if (-not $guidMap.ContainsKey($rule.Id)) { $guidMap[$rule.Id] = $replacement } + $replacement = $guidMap[$rule.Id] + } + $text = $rule.Regex.Replace($text, $replacement) + $hits.Add("$($rule.Id) x$count") + } + + $lineNo = 0 + foreach ($line in ($text -split "`n")) { + $lineNo++ + foreach ($rx in $leftoverRegexes) { + if ($rx.IsMatch($line)) { + $snippet = (Restore-Text $line).Trim() + if ($snippet.Length -gt 120) { $snippet = $snippet.Substring(0, 120) } + $leftovers.Add([pscustomobject]@{ File = $file.FullName.Substring($rootLen); Line = $lineNo; Text = $snippet }) + break + } + } + } + + $updated = Restore-Text $text + if ($updated -ne $original) { + $changed.Add([pscustomobject]@{ File = $file.FullName.Substring($rootLen); Rules = ($hits -join ', ') }) + if ($Apply) { + $body = $utf8.GetBytes($updated) + $out = if ($hasBom) { $bom + $body } else { $body } + [System.IO.File]::WriteAllBytes($file.FullName, $out) + } + } +} + +# ---------- Rename pass (Values target only) ---------- +$renames = [System.Collections.Generic.List[object]]::new() +if ($isValues) { + $items = Get-ChildItem -Path $Path -Recurse -Force | Where-Object { + $relative = $_.FullName.Substring($rootLen) + $segments = $relative -split '[\\/]' + -not ($segments | Where-Object { $excludeDirs -contains $_ }) + } | Sort-Object { $_.FullName.Length } -Descending + + foreach ($item in $items) { + $newName = $item.Name + foreach ($r in $map.renames) { + $to = $r.value + foreach ($key in $values.Keys) { $to = $to.Replace("{$key}", $values[$key]) } + $newName = $newName.Replace($r.find, $to) + } + if ($newName -ne $item.Name) { + $renames.Add([pscustomobject]@{ From = $item.FullName.Substring($rootLen); To = $newName }) + if ($Apply) { Rename-Item -LiteralPath $item.FullName -NewName $newName } + } + } +} + +# ---------- Report ---------- +$mode = if ($Apply) { 'APPLIED' } else { 'DRY RUN (use -Apply to write)' } +Write-Host "Update-Branding [$Target] $mode" +Write-Host "Files scanned: $($files.Count); files changed: $($changed.Count); renames: $($renames.Count)" +if (-not $isValues) { Write-Host "Tokens injected into: $Path" } +$changed | ForEach-Object { Write-Host " ~ $($_.File) [$($_.Rules)]" } +$renames | ForEach-Object { Write-Host " > $($_.From) -> $($_.To)" } + +if ($leftovers.Count -gt 0) { + Write-Warning "Leftover branding in $($leftovers.Count) line(s):" + $leftovers | Select-Object -First 50 | ForEach-Object { Write-Host " ! $($_.File):$($_.Line) $($_.Text)" } + if ($leftovers.Count -gt 50) { Write-Host " ... $($leftovers.Count - 50) more" } +} + +if ($Apply -and $isValues) { + Write-Host '' + Write-Host 'Next steps:' + Write-Host ' 1. Create Entra app registrations and user-secrets for the new product (see README: New-EntraAppRegistrations.ps1).' + if (-not $KeepUserSecretsIds) { Write-Host ' 2. UserSecretsId values were regenerated; previously stored secrets do not carry over.' } + Write-Host ' 3. Review the diff, build and test before committing.' +} + +if ($leftovers.Count -gt 0 -and ($FailOnLeftover -or ($Apply -and $isValues))) { exit 1 } +exit 0 diff --git a/branding.map.json b/branding.map.json new file mode 100644 index 0000000..46493fd --- /dev/null +++ b/branding.map.json @@ -0,0 +1,86 @@ +{ + "version": 1, + "description": "Data-only brand rules shared by Update-Branding.ps1 and the agent-framework-template packaging scripts. No effect on build or run.", + "rules": [ + { "id": "usersecrets-api", "find": "bf5b92ab-f3fe-4af6-b299-583240397247", "kind": "literal", "value": "@guid", + "tokens": { "DotnetNew": "__USER_SECRETS_ID_API__", "Vsix": "$guid1$" } }, + { "id": "usersecrets-web", "find": "c2325466-9b0d-494a-984a-11ad18457d3f", "kind": "literal", "value": "@guid", + "tokens": { "DotnetNew": "__USER_SECRETS_ID_WEB__", "Vsix": "$guid2$" } }, + { "id": "usersecrets-tests", "find": "998fefad-d248-4bad-9d33-8a060711dd88", "kind": "literal", "value": "@guid", + "tokens": { "DotnetNew": "__USER_SECRETS_ID_TESTS__", "Vsix": "$guid3$" } }, + + { "id": "product-seo", "find": "GoodToCode Quick-start for Microsoft Agent Framework", "kind": "literal", "value": "{NewProduct}", + "tokens": { "DotnetNew": "__PRODUCT_NAME__", "Vsix": null } }, + { "id": "product-full", "find": "GoodToCode Agent Framework Quick-start", "kind": "literal", "value": "{NewProduct}", + "tokens": { "DotnetNew": "__PRODUCT_NAME__", "Vsix": null } }, + { "id": "product-title", "find": "Quick-start for Microsoft Agent Framework", "kind": "literal", "value": "{NewProduct}", + "tokens": { "DotnetNew": "__PRODUCT_NAME__", "Vsix": null } }, + { "id": "product-prose", "find": "Microsoft Agent Framework Quick-start", "kind": "literal", "value": "{NewProduct}", + "tokens": { "DotnetNew": "__PRODUCT_NAME__", "Vsix": null } }, + { "id": "product-org", "find": "GoodToCode Agent Framework", "kind": "literal", "value": "{NewProduct}", + "tokens": { "DotnetNew": "__PRODUCT_NAME__", "Vsix": null } }, + + { "id": "repo-slug", "find": "goodtocode/agent-framework-quick-start", "kind": "literal", "value": "{RepoOwner}/{RepoName}", + "tokens": { "DotnetNew": "__REPO_OWNER__/__REPO_NAME__", "Vsix": null } }, + { "id": "repo-name", "find": "agent-framework-quick-start", "kind": "literal", "value": "{RepoName}", + "tokens": { "DotnetNew": "__REPO_NAME__", "Vsix": null } }, + + { "id": "tenant-host", "find": "goodtocodesecuredev.ciamlogin.com", "kind": "literal", "value": "{TenantHost}", + "tokens": { "DotnetNew": "__TENANT_HOST__", "Vsix": "your-tenant-name.ciamlogin.com" } }, + + { "id": "contact-email", "find": "developers@goodtocode.com", "kind": "literal", "value": "{ContactEmail}", + "tokens": { "DotnetNew": "__CONTACT_EMAIL__", "Vsix": null } }, + { "id": "company-domain", "find": "(? Date: Fri, 23 Oct 2026 15:10:25 -0700 Subject: [PATCH 2/2] fixed vulnerability --- src/Presentation.Api/Presentation.Api.csproj | 2 ++ src/Presentation.Web/Presentation.Web.csproj | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Presentation.Api/Presentation.Api.csproj b/src/Presentation.Api/Presentation.Api.csproj index 2ff607f..89221bf 100644 --- a/src/Presentation.Api/Presentation.Api.csproj +++ b/src/Presentation.Api/Presentation.Api.csproj @@ -41,6 +41,8 @@ + + diff --git a/src/Presentation.Web/Presentation.Web.csproj b/src/Presentation.Web/Presentation.Web.csproj index d1a0be5..f17eed1 100644 --- a/src/Presentation.Web/Presentation.Web.csproj +++ b/src/Presentation.Web/Presentation.Web.csproj @@ -31,6 +31,8 @@ - + + +