Schema migrates dev to prod only; catalog rows merge by updated_at and images by mtime with union merge and conflict reporting. Co-authored-by: Cursor <cursoragent@cursor.com>
235 lines
7.3 KiB
PowerShell
235 lines
7.3 KiB
PowerShell
# Dev <-> prod harmonize orchestrator (incremental merge; not full replace).
|
|
#
|
|
# Usage (from repo root):
|
|
# npm run harmonize
|
|
# npm run harmonize -- -DryRun
|
|
# npm run harmonize -- -Config infra/deploy/harmonize.config.json
|
|
#
|
|
# Requires infra/deploy/harmonize.config.json (copy from harmonize.config.example.json)
|
|
# or infra/deploy/devtoprod.config.json with optional "harmonize" section.
|
|
|
|
param(
|
|
[string]$Config = (Join-Path $PSScriptRoot "..\deploy\harmonize.config.json"),
|
|
[switch]$DryRun
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
. (Join-Path $PSScriptRoot "lib\Deploy-CliResult.ps1")
|
|
|
|
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
|
Set-Location $RepoRoot
|
|
|
|
$BannerWidth = 64
|
|
$CompletedSteps = [System.Collections.Generic.List[string]]::new()
|
|
$FailedStep = $null
|
|
|
|
function Write-HarmonizeBanner {
|
|
param([bool]$Success, [string]$FailedAt = '')
|
|
|
|
Write-Host ''
|
|
Write-Host ('=' * $BannerWidth)
|
|
if ($Success) {
|
|
Write-Host ' HARMONIZE SUCCEEDED - dev and prod converged'
|
|
Write-Host (' Steps: ' + ($CompletedSteps -join ', '))
|
|
} else {
|
|
Write-Host " HARMONIZE FAILED at step: $FailedAt"
|
|
if ($CompletedSteps.Count -gt 0) {
|
|
Write-Host (' Completed: ' + ($CompletedSteps -join ', '))
|
|
}
|
|
}
|
|
Write-Host ('=' * $BannerWidth)
|
|
}
|
|
|
|
function Invoke-Npm {
|
|
param([string]$Script, [string[]]$ExtraArgs = @())
|
|
$npmArgs = @('run', $Script)
|
|
if ($ExtraArgs.Count -gt 0) {
|
|
$npmArgs += '--'
|
|
$npmArgs += $ExtraArgs
|
|
}
|
|
Write-Host ("Running npm: npm " + ($npmArgs -join ' ')) -ForegroundColor DarkGray
|
|
$prevEap = $ErrorActionPreference
|
|
$ErrorActionPreference = 'Continue'
|
|
$output = & npm @npmArgs 2>&1
|
|
$exit = $LASTEXITCODE
|
|
$ErrorActionPreference = $prevEap
|
|
foreach ($line in $output) { Write-Host $line }
|
|
return $exit
|
|
}
|
|
|
|
function Invoke-Step {
|
|
param([string]$Name, [scriptblock]$Action)
|
|
|
|
if ($DryRun) {
|
|
Write-Host "[dry-run] Would run: $Name"
|
|
$script:CompletedSteps.Add($Name)
|
|
return $true
|
|
}
|
|
|
|
Write-Host ''
|
|
Write-Host "--- Step: $Name ---" -ForegroundColor Cyan
|
|
try {
|
|
$code = & $Action
|
|
if ($null -eq $code) { $code = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } else { 0 } }
|
|
if ($code -ne 0) {
|
|
$script:FailedStep = $Name
|
|
return $false
|
|
}
|
|
$script:CompletedSteps.Add($Name)
|
|
return $true
|
|
} catch {
|
|
Write-Host $_.Exception.Message -ForegroundColor Red
|
|
$script:FailedStep = $Name
|
|
return $false
|
|
}
|
|
}
|
|
|
|
function Resolve-ConfigPath {
|
|
if (Test-Path $Config) { return (Resolve-Path $Config).Path }
|
|
$fallback = Join-Path $PSScriptRoot "..\deploy\devtoprod.config.json"
|
|
if (Test-Path $fallback) {
|
|
Write-Host "Using fallback config: $fallback" -ForegroundColor DarkYellow
|
|
return (Resolve-Path $fallback).Path
|
|
}
|
|
throw "Missing harmonize config. Copy infra/deploy/harmonize.config.example.json to infra/deploy/harmonize.config.json"
|
|
}
|
|
|
|
function Get-HarmonizeSteps {
|
|
param([object]$Raw)
|
|
|
|
$harmonize = $Raw.harmonize
|
|
if ($null -eq $harmonize) { $harmonize = $Raw }
|
|
|
|
$schemaChanged = $false
|
|
if ($null -ne $harmonize.schemaChanged) { $schemaChanged = [bool]$harmonize.schemaChanged }
|
|
elseif ($null -ne $Raw.schemaChanged) { $schemaChanged = [bool]$Raw.schemaChanged }
|
|
|
|
$steps = @{
|
|
backupDev = $true
|
|
backupProd = $true
|
|
schema = $schemaChanged
|
|
db = $true
|
|
images = $true
|
|
verify = $false
|
|
}
|
|
|
|
$cfgSteps = $harmonize.steps
|
|
if ($null -ne $cfgSteps) {
|
|
foreach ($key in $cfgSteps.PSObject.Properties.Name) {
|
|
$steps[$key] = [bool]$cfgSteps.$key
|
|
}
|
|
}
|
|
|
|
return $steps
|
|
}
|
|
|
|
function Set-SmbCredentials {
|
|
param([object]$Smb)
|
|
|
|
if ($Smb.user -and $Smb.password) {
|
|
$env:SMB_USER = [string]$Smb.user
|
|
$env:SMB_PASSWORD = [string]$Smb.password
|
|
}
|
|
}
|
|
|
|
function Test-ApiUrl {
|
|
param([string]$Url)
|
|
if (-not $Url) { return $true }
|
|
try {
|
|
$resp = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 20
|
|
Write-Host " OK $Url ($($resp.StatusCode))" -ForegroundColor DarkGreen
|
|
return $true
|
|
} catch {
|
|
Write-Host " FAIL $Url : $($_.Exception.Message)" -ForegroundColor Red
|
|
return $false
|
|
}
|
|
}
|
|
|
|
$configPath = Resolve-ConfigPath
|
|
$raw = Get-Content -LiteralPath $configPath -Raw | ConvertFrom-Json
|
|
$steps = Get-HarmonizeSteps -Raw $raw
|
|
$harmonize = if ($raw.harmonize) { $raw.harmonize } else { $raw }
|
|
|
|
if ($DryRun -or ($harmonize.dryRun -eq $true)) {
|
|
$DryRun = $true
|
|
$env:CONFIRM_PROD = '1'
|
|
}
|
|
|
|
Set-SmbCredentials -Smb $raw.smb
|
|
if ($harmonize.smb) { Set-SmbCredentials -Smb $harmonize.smb }
|
|
|
|
$preferArg = @()
|
|
if ($harmonize.prefer) {
|
|
$preferArg = @("--prefer=$($harmonize.prefer)")
|
|
}
|
|
|
|
$dryRunArgs = @()
|
|
if ($DryRun) { $dryRunArgs = @('--dry-run') }
|
|
|
|
Write-Host "Harmonize config: $configPath"
|
|
Write-Host ("Steps enabled: " + (($steps.GetEnumerator() | Where-Object { $_.Value } | ForEach-Object { $_.Key }) -join ', '))
|
|
|
|
if (-not $DryRun) {
|
|
$env:CONFIRM_PROD = '1'
|
|
}
|
|
|
|
if ($steps.backupDev) {
|
|
if (-not (Invoke-Step 'backupDev' { Invoke-Npm 'dev:db:backup' })) {
|
|
Write-HarmonizeBanner -Success $false -FailedAt 'backupDev'
|
|
Write-DeployCliResult -Script 'harmonize' -Success $false -Summary 'Failed at backupDev.'
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
if ($steps.backupProd) {
|
|
if (-not (Invoke-Step 'backupProd' { Invoke-Npm 'prod:db:backup' })) {
|
|
Write-HarmonizeBanner -Success $false -FailedAt 'backupProd'
|
|
Write-DeployCliResult -Script 'harmonize' -Success $false -Summary 'Failed at backupProd.'
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
if ($steps.schema) {
|
|
if (-not (Invoke-Step 'schema' { Invoke-Npm 'harmonize:schema' })) {
|
|
Write-HarmonizeBanner -Success $false -FailedAt 'schema'
|
|
Write-DeployCliResult -Script 'harmonize' -Success $false -Summary 'Failed at schema migrate.'
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
if ($steps.db) {
|
|
if (-not (Invoke-Step 'db' { Invoke-Npm 'harmonize:db' @dryRunArgs @preferArg })) {
|
|
Write-HarmonizeBanner -Success $false -FailedAt 'db'
|
|
Write-DeployCliResult -Script 'harmonize' -Success $false -Summary 'Failed at DB harmonize.'
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
if ($steps.images) {
|
|
if (-not (Invoke-Step 'images' { Invoke-Npm 'harmonize:images' @dryRunArgs })) {
|
|
Write-HarmonizeBanner -Success $false -FailedAt 'images'
|
|
Write-DeployCliResult -Script 'harmonize' -Success $false -Summary 'Failed at image harmonize.'
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
if ($steps.verify -and -not $DryRun) {
|
|
$verify = $harmonize.verify
|
|
if (-not $verify) { $verify = $raw.verify }
|
|
$ok = $true
|
|
if (-not (Invoke-Step 'verify' {
|
|
if ($verify.devUrl -and -not (Test-ApiUrl $verify.devUrl)) { return 1 }
|
|
if ($verify.publicUrl -and -not (Test-ApiUrl $verify.publicUrl)) { return 1 }
|
|
return 0
|
|
})) {
|
|
Write-HarmonizeBanner -Success $false -FailedAt 'verify'
|
|
Write-DeployCliResult -Script 'harmonize' -Success $false -Summary 'Verify step failed.'
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
Write-HarmonizeBanner -Success $true
|
|
$summary = if ($DryRun) { 'Harmonize dry-run complete (no changes written).' } else { 'Harmonize complete.' }
|
|
Write-DeployCliResult -Script 'harmonize' -Success $true -Summary $summary -Details @("Config: $configPath")
|
|
exit 0
|