Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions Update-Branding.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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" '<Solution />'
Set-Content "$root\.github\workflows\gtc-agent-standalone-iac.yml" 'name: gtc-agent-standalone-iac'
Set-Content "$root\src\App\App.csproj" @'
<Project><PropertyGroup>
<RootNamespace>Goodtocode.AgentFramework.App</RootNamespace>
<UserSecretsId>bf5b92ab-f3fe-4af6-b299-583240397247</UserSecretsId>
</PropertyGroup><ItemGroup>
<PackageReference Include="Goodtocode.Mediator" Version="1.0.0" />
<PackageReference Include="Goodtocode.Agents.Playbook" Version="1.0.0" />
</ItemGroup></Project>
'@
Set-Content "$root\src\App\Page.razor" '<title>Quick-start for Microsoft Agent Framework</title><p>GoodToCode | developers@goodtocode.com</p>'
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'
}
}
252 changes: 252 additions & 0 deletions Update-Branding.ps1
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading