diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..27172ee
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,15 @@
+# Paths excluded from `git archive` (used by scripts/build-release.ps1 to assemble the
+# deployable file set). Everything else - app/, core/, public/, db/migrations/, scripts/ -
+# ships to production.
+/.claude export-ignore
+/.gitattributes export-ignore
+/.gitignore export-ignore
+/AGENTS.md export-ignore
+/CLAUDE.md export-ignore
+/README.md export-ignore
+/TESTING.md export-ignore
+/applicationhost.config export-ignore
+/run_site.cmd export-ignore
+/widget export-ignore
+/docs export-ignore
+/tests export-ignore
diff --git a/.gitignore b/.gitignore
index e0f8c72..44adb21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,10 @@ db/webdata.accdb
*.laccdb
# Personal Claude Code permission grants for this machine/session - not team config
-.claude/settings.local.json
\ No newline at end of file
+.claude/settings.local.json
+
+# Deploy target info (SSH host/user) - environment-specific, not team config
+scripts/deployinfo.txt
+
+# Local build/deploy scratch output
+/dist
\ No newline at end of file
diff --git a/public/web.config.production.template b/public/web.config.production.template
new file mode 100644
index 0000000..e74e448
--- /dev/null
+++ b/public/web.config.production.template
@@ -0,0 +1,114 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/scripts/build-release.ps1 b/scripts/build-release.ps1
new file mode 100644
index 0000000..03b4281
--- /dev/null
+++ b/scripts/build-release.ps1
@@ -0,0 +1,89 @@
+<#
+ Builds a deployable release folder for the Purple Envelope Orders Site.
+
+ "Build" for this Classic ASP app just means: assemble the exact file set that should ship
+ to production, with the production web.config swapped in. There's no compile step.
+
+ - Archives the given git ref via `git archive`, which respects .gitattributes
+ export-ignore rules (drops docs/, tests/, .claude/, dev-only config, etc. - see
+ .gitattributes for the full list).
+ - Enforces $KeepTopLevel below as the authoritative allow-list: anything extracted from
+ the archive that isn't on this list gets deleted. This is the real safety net - a file
+ added to the repo root later and never added to .gitattributes would otherwise ship to
+ production by default. Here, it has to be explicitly added to $KeepTopLevel to ship.
+ - Overwrites public/web.config in the archive with public/web.config.production.template.
+ - Leaves the assembled tree in -OutDir, ready to zip and ship.
+
+ Usage:
+ powershell -File scripts\build-release.ps1
+ powershell -File scripts\build-release.ps1 -Ref master -OutDir dist\release
+#>
+
+param(
+ [string]$Ref = 'HEAD',
+ [string]$OutDir = ''
+)
+
+$ErrorActionPreference = 'Stop'
+
+# Everything the live site needs to run: app code, framework internals, the IIS root, and
+# the migration runner (+ its generator siblings, harmless to ship alongside it). Only
+# db\migrations is kept under db - never db\webdata.accdb, even if something ever put a
+# stray copy there.
+$KeepTopLevel = @('app', 'core', 'public', 'scripts')
+$KeepUnderDb = @('migrations')
+
+$repoRoot = Split-Path $PSScriptRoot -Parent
+Push-Location $repoRoot
+try {
+ if([string]::IsNullOrWhiteSpace($OutDir)){
+ $OutDir = Join-Path $repoRoot ('dist\release_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
+ }
+ if(Test-Path $OutDir){
+ Remove-Item -Recurse -Force $OutDir
+ }
+ New-Item -ItemType Directory -Force -Path $OutDir | Out-Null
+
+ $templatePath = Join-Path $repoRoot 'public\web.config.production.template'
+ if(!(Test-Path $templatePath)){
+ throw "public\web.config.production.template not found - can't build a production release without it."
+ }
+
+ Write-Host "Archiving $Ref (honoring .gitattributes export-ignore rules)..."
+ $archivePath = Join-Path $OutDir '_archive.zip'
+ git archive --format=zip --output $archivePath $Ref
+ if($LASTEXITCODE -ne 0){ throw 'git archive failed' }
+
+ Expand-Archive -Path $archivePath -DestinationPath $OutDir -Force
+ Remove-Item $archivePath
+
+ Write-Host "Pruning to the allow-list: $($KeepTopLevel -join ', '), db\$($KeepUnderDb -join ', db\')"
+ Get-ChildItem -Path $OutDir -Force | ForEach-Object {
+ if($_.Name -notin $KeepTopLevel){
+ Write-Host " removing (not on allow-list): $($_.Name)"
+ Remove-Item -Recurse -Force $_.FullName
+ }
+ }
+ $dbDir = Join-Path $OutDir 'db'
+ if(Test-Path $dbDir){
+ Get-ChildItem -Path $dbDir -Force | ForEach-Object {
+ if($_.Name -notin $KeepUnderDb){
+ Write-Host " removing (not on allow-list): db\$($_.Name)"
+ Remove-Item -Recurse -Force $_.FullName
+ }
+ }
+ }
+
+ if(!(Test-Path (Join-Path $OutDir 'public'))){
+ throw "Release has no public\ folder after pruning - check `$KeepTopLevel in this script"
+ }
+
+ $liveConfig = Join-Path $OutDir 'public\web.config'
+ Copy-Item $templatePath $liveConfig -Force
+ Remove-Item (Join-Path $OutDir 'public\web.config.production.template') -ErrorAction SilentlyContinue
+
+ Write-Host "Release built at $OutDir"
+ Write-Host " public\web.config swapped in from web.config.production.template"
+} finally {
+ Pop-Location
+}
diff --git a/scripts/deploy-iis-git.ps1 b/scripts/deploy-iis-git.ps1
deleted file mode 100644
index 3326f8a..0000000
--- a/scripts/deploy-iis-git.ps1
+++ /dev/null
@@ -1,324 +0,0 @@
-<#
- Deploy asp-territory to an existing IIS site, locally or over SSH.
-
- Remote mode:
- - Copies this script to the remote Windows host with scp
- - Executes it remotely via ssh in -RunRemoteCore mode
- - Preserves the remote site's current DB path unless -DbPath is passed
- - Can run standard migrations and an optional legacy migration script
-
- Local / remote core behavior:
- - Infers IIS site/app pool/work dir from the existing site when possible
- - Stops the site/app pool while deploying
- - Clones/pulls and hard-resets to origin/
- - Points IIS at \public
- - Reapplies the effective DB path in public\web.config
- - Grants IIS AppPool rights to the DB folder
- - Runs migrations
- - Restarts the site/app pool and smoke tests key routes
-#>
-
-param(
- [string]$Repo = 'git@onefortheroadgit.sytes.net:dcovington/asp-classic-unified-framework.git',
- [string]$Branch = 'main',
- [string]$SiteName = 'ttasp',
- [string]$AppPool = '',
- [string]$WorkDir = '',
- [string]$PublicDir = '',
- [string]$BaseUrl = '',
- [string]$DbPath = '',
-
- [switch]$RunMigrations = $true,
- [switch]$SkipLegacyIsBusinessMigration,
- [string]$LegacyMigrationScript = 'scripts\migrate_isbusiness_to_households.vbs',
-
- [switch]$UseRemoteSsh,
- [string]$RemoteTarget = '',
- [int]$RemotePort = 22,
- [string]$SshExe = 'ssh',
- [string]$ScpExe = 'scp',
- [switch]$RunRemoteCore
-)
-
-$ErrorActionPreference = 'Stop'
-
-function Ensure-Dir {
- param([string]$Path)
- if([string]::IsNullOrWhiteSpace($Path)){ return }
- if(!(Test-Path $Path)){
- New-Item -ItemType Directory -Force -Path $Path | Out-Null
- }
-}
-
-function Ensure-Command {
- param([string]$Name)
- if(!(Get-Command $Name -ErrorAction SilentlyContinue)){
- throw "$Name not found on PATH"
- }
-}
-
-function Get-DefaultRemoteTargetFromInfo {
- $infoPath = Join-Path $PSScriptRoot 'depolyinfo.txt'
- if(!(Test-Path $infoPath)){ return '' }
-
- $sshLine = Get-Content $infoPath | Where-Object { $_ -match '^\s*ssh\s+' } | Select-Object -First 1
- if([string]::IsNullOrWhiteSpace($sshLine)){ return '' }
-
- return ($sshLine -replace '^\s*ssh\s+', '').Trim()
-}
-
-function ConvertTo-PowerShellLiteral {
- param([AllowNull()][string]$Value)
- if($null -eq $Value){ return "''" }
- return "'" + ($Value -replace "'", "''") + "'"
-}
-
-function ConvertTo-CmdDoubleQuoted {
- param([AllowNull()][string]$Value)
- if($null -eq $Value){ return '""' }
- return '"' + ($Value -replace '"', '""') + '"'
-}
-
-function Get-DataSourceFromConfig {
- param([string]$ConfigPath)
- if(!(Test-Path $ConfigPath)){ return '' }
-
- $raw = Get-Content $ConfigPath -Raw
- $match = [regex]::Match($raw, 'Data Source=([^;]+);', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
- if($match.Success){
- return $match.Groups[1].Value.Trim()
- }
-
- return ''
-}
-
-function Set-DataSourceInConfig {
- param(
- [string]$ConfigPath,
- [string]$EffectiveDbPath
- )
-
- if(!(Test-Path $ConfigPath)){ return }
-
- $raw = Get-Content $ConfigPath -Raw
- $updated = [regex]::Replace(
- $raw,
- 'Data Source=[^;]*;',
- ('Data Source=' + $EffectiveDbPath + ';'),
- [System.Text.RegularExpressions.RegexOptions]::IgnoreCase
- )
-
- if($updated -ne $raw){
- Set-Content -Path $ConfigPath -Value $updated -Encoding UTF8
- Write-Host "Updated ConnectionString Data Source to $EffectiveDbPath"
- }
-}
-
-function Get-BaseUrlFromSite {
- param($Site)
-
- $httpBind = $Site.Bindings.Collection | Where-Object { $_.protocol -eq 'http' } | Select-Object -First 1
- if($httpBind){
- $parts = $httpBind.bindingInformation.Split(':')
- $port = $parts[1]
- if([string]::IsNullOrWhiteSpace($port)){ $port = '80' }
- return ('http://127.0.0.1:' + $port)
- }
-
- return 'http://127.0.0.1'
-}
-
-function Invoke-DeployCore {
- Ensure-Command git
- Import-Module WebAdministration
-
- $site = Get-Website -Name $SiteName
- if(!$site){ throw "IIS site not found: $SiteName" }
-
- if([string]::IsNullOrWhiteSpace($AppPool)){
- $AppPool = $site.applicationPool
- }
-
- if([string]::IsNullOrWhiteSpace($PublicDir)){
- $PublicDir = $site.physicalPath
- }
-
- if([string]::IsNullOrWhiteSpace($WorkDir)){
- $pd = [Environment]::ExpandEnvironmentVariables($PublicDir)
- $pd = $pd.Trim().Trim('"')
- $pd = $pd.TrimEnd('\','/')
-
- if((Split-Path $pd -Leaf).ToLower() -eq 'public'){
- $WorkDir = Split-Path $pd -Parent
- } else {
- $WorkDir = $pd
- }
- }
-
- if([string]::IsNullOrWhiteSpace($BaseUrl)){
- $BaseUrl = Get-BaseUrlFromSite -Site $site
- }
-
- $currentPublicDir = $PublicDir
- $currentConfigPath = Join-Path $currentPublicDir 'web.config'
- $effectiveDbPath = $DbPath
- if([string]::IsNullOrWhiteSpace($effectiveDbPath)){
- $effectiveDbPath = Get-DataSourceFromConfig -ConfigPath $currentConfigPath
- }
-
- if([string]::IsNullOrWhiteSpace($effectiveDbPath)){
- throw 'No database path was provided and no existing Data Source could be read from the current web.config'
- }
-
- Write-Host "Stopping IIS site $SiteName and app pool $AppPool"
- try { Stop-Website -Name $SiteName } catch { }
- try { Stop-WebAppPool -Name $AppPool } catch { }
-
- Ensure-Dir (Split-Path $WorkDir -Parent)
- if((Test-Path $WorkDir) -and !(Test-Path (Join-Path $WorkDir '.git'))){
- $bak = ($WorkDir.TrimEnd('\') + '_pre_git_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
- Write-Host "Existing non-git folder detected. Moving to $bak"
- Move-Item -Force $WorkDir $bak
- }
-
- if(!(Test-Path $WorkDir)){
- Write-Host "Cloning $Repo -> $WorkDir"
- git clone $Repo $WorkDir
- }
-
- Push-Location $WorkDir
- try {
- Write-Host "Updating to origin/$Branch"
- git fetch origin
- git checkout $Branch
- & git reset --hard ("origin/" + $Branch)
- } finally {
- Pop-Location
- }
-
- if((Split-Path $WorkDir -Leaf).ToLower() -eq 'public'){
- $WorkDir = Split-Path $WorkDir -Parent
- }
-
- $PublicDir = Join-Path $WorkDir 'public'
- $cfg = Join-Path $PublicDir 'web.config'
-
- Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $PublicDir
- Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPool
- Set-ItemProperty ('IIS:\AppPools\' + $AppPool) -Name processModel.identityType -Value NetworkService
-
- Set-DataSourceInConfig -ConfigPath $cfg -EffectiveDbPath $effectiveDbPath
-
- $dbFolder = Split-Path $effectiveDbPath -Parent
- if(!(Test-Path $dbFolder)){
- Ensure-Dir $dbFolder
- }
- icacls $dbFolder /grant ("IIS AppPool\" + $AppPool + ":(OI)(CI)(M)") /T | Out-Null
-
- Push-Location $WorkDir
- try {
- if($RunMigrations){
- Write-Host 'Running standard migrations'
- cscript //nologo scripts\runMigrations.vbs up
- }
-
- if(-not $SkipLegacyIsBusinessMigration){
- $legacyPath = Join-Path $WorkDir $LegacyMigrationScript
- if(!(Test-Path $legacyPath)){
- throw "Legacy migration script not found: $legacyPath"
- }
-
- Write-Host 'Running legacy IsBusiness migration'
- cscript //nologo $legacyPath $effectiveDbPath
- }
- } finally {
- Pop-Location
- }
-
- if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){
- Restart-WebAppPool -Name $AppPool
- } else {
- Start-WebAppPool -Name $AppPool
- }
- Start-Website $SiteName
-
- Start-Sleep -Seconds 1
-
- $paths = @('/','/territories','/households','/householder-names')
- foreach($path in $paths){
- $url = $BaseUrl + $path
- $response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 30
- Write-Host ("OK " + $path + ' -> ' + $response.StatusCode)
- }
-
- Write-Host 'Deploy complete.'
-}
-
-if($UseRemoteSsh -and !$RunRemoteCore -and [string]::IsNullOrWhiteSpace($RemoteTarget)){
- $RemoteTarget = Get-DefaultRemoteTargetFromInfo
-}
-
-if($UseRemoteSsh -and !$RunRemoteCore -and -not [string]::IsNullOrWhiteSpace($RemoteTarget)){
- Ensure-Command $SshExe
- Ensure-Command $ScpExe
-
- $remoteScriptPath = 'C:\Windows\Temp\deploy-test-territory-git.ps1'
- $scpDestination = "${RemoteTarget}:C:/Windows/Temp/deploy-test-territory-git.ps1"
-
- Write-Host "Copying deploy script to $RemoteTarget"
- & $ScpExe -P $RemotePort $PSCommandPath $scpDestination
- if($LASTEXITCODE -ne 0){ throw 'scp failed' }
-
- $remoteCommand = New-Object System.Collections.Generic.List[string]
- @(
- 'powershell',
- '-NoProfile',
- '-ExecutionPolicy', 'Bypass',
- '-File', (ConvertTo-CmdDoubleQuoted $remoteScriptPath),
- '-RunRemoteCore',
- '-Repo', (ConvertTo-CmdDoubleQuoted $Repo),
- '-Branch', (ConvertTo-CmdDoubleQuoted $Branch),
- '-SiteName', (ConvertTo-CmdDoubleQuoted $SiteName)
- ) | ForEach-Object { [void]$remoteCommand.Add($_) }
-
- if(-not [string]::IsNullOrWhiteSpace($AppPool)){
- [void]$remoteCommand.Add('-AppPool')
- [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $AppPool))
- }
-
- if(-not [string]::IsNullOrWhiteSpace($WorkDir)){
- [void]$remoteCommand.Add('-WorkDir')
- [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $WorkDir))
- }
-
- if(-not [string]::IsNullOrWhiteSpace($PublicDir)){
- [void]$remoteCommand.Add('-PublicDir')
- [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $PublicDir))
- }
-
- if(-not [string]::IsNullOrWhiteSpace($BaseUrl)){
- [void]$remoteCommand.Add('-BaseUrl')
- [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $BaseUrl))
- }
-
- if(-not [string]::IsNullOrWhiteSpace($DbPath)){
- [void]$remoteCommand.Add('-DbPath')
- [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $DbPath))
- }
-
- if(-not [string]::IsNullOrWhiteSpace($LegacyMigrationScript)){
- [void]$remoteCommand.Add('-LegacyMigrationScript')
- [void]$remoteCommand.Add((ConvertTo-CmdDoubleQuoted $LegacyMigrationScript))
- }
-
- if($RunMigrations){ $remoteCommand += '-RunMigrations' }
- if($SkipLegacyIsBusinessMigration){ $remoteCommand += '-SkipLegacyIsBusinessMigration' }
-
- Write-Host "Executing remote deploy on $RemoteTarget"
- & $SshExe -p $RemotePort $RemoteTarget ($remoteCommand -join ' ')
- if($LASTEXITCODE -ne 0){ throw 'remote deploy failed' }
-
- exit 0
-}
-
-Invoke-DeployCore
diff --git a/scripts/deploy-iis-remote-apply.ps1 b/scripts/deploy-iis-remote-apply.ps1
new file mode 100644
index 0000000..604e4a8
--- /dev/null
+++ b/scripts/deploy-iis-remote-apply.ps1
@@ -0,0 +1,87 @@
+<#
+ Runs ON the IIS server (invoked over SSH by scripts\deploy-iis.ps1). Not meant to be run
+ by hand except for troubleshooting a stuck deploy.
+
+ - Stops the site/app pool
+ - Wipes RemoteDir and re-extracts the release zip into it (safe: the DB and error log
+ live outside RemoteDir, at DbPath/ErrorLogDir, so a full wipe never touches them)
+ - Points IIS at RemoteDir\public
+ - Ensures the app pool identity has modify rights on the persistent data folder
+ - Runs pending migrations (32-bit cscript - ACE OLEDB is x86-only)
+ - Restarts the site/app pool
+#>
+
+param(
+ [Parameter(Mandatory = $true)][string]$ZipPath,
+ [Parameter(Mandatory = $true)][string]$RemoteDir,
+ [Parameter(Mandatory = $true)][string]$SiteName,
+ [Parameter(Mandatory = $true)][string]$AppPool,
+ [Parameter(Mandatory = $true)][string]$DbPath,
+ [switch]$RunMigrations = $true
+)
+
+$ErrorActionPreference = 'Stop'
+Import-Module WebAdministration
+
+if(!(Get-Website -Name $SiteName -ErrorAction SilentlyContinue)){
+ throw "IIS site '$SiteName' does not exist yet. Create the site and app pool once manually (or via a one-time setup script) before running this deploy."
+}
+if(!(Test-Path ('IIS:\AppPools\' + $AppPool))){
+ throw "App pool '$AppPool' does not exist yet. Create it once manually before running this deploy."
+}
+
+Write-Host "Stopping IIS site $SiteName and app pool $AppPool"
+try { Stop-Website -Name $SiteName } catch { }
+try { Stop-WebAppPool -Name $AppPool } catch { }
+
+if(Test-Path $RemoteDir){
+ Write-Host "Wiping $RemoteDir"
+ Remove-Item -Recurse -Force (Join-Path $RemoteDir '*') -ErrorAction SilentlyContinue
+} else {
+ New-Item -ItemType Directory -Force -Path $RemoteDir | Out-Null
+}
+
+Write-Host "Extracting $ZipPath -> $RemoteDir"
+Expand-Archive -Path $ZipPath -DestinationPath $RemoteDir -Force
+Remove-Item $ZipPath -ErrorAction SilentlyContinue
+
+$publicDir = Join-Path $RemoteDir 'public'
+if(!(Test-Path $publicDir)){
+ throw "No public\ folder in the extracted release - deploy aborted, site left stopped for inspection."
+}
+
+# Persistent data folder (DB + error log) - lives outside RemoteDir so it survives the wipe
+# above. Create it and grant the app pool identity modify rights.
+$dataDir = Split-Path $DbPath -Parent
+if(!(Test-Path $dataDir)){
+ New-Item -ItemType Directory -Force -Path $dataDir | Out-Null
+}
+$logDir = Join-Path $dataDir 'logs'
+if(!(Test-Path $logDir)){
+ New-Item -ItemType Directory -Force -Path $logDir | Out-Null
+}
+icacls $dataDir /grant ("IIS AppPool\" + $AppPool + ":(OI)(CI)(M)") /T | Out-Null
+
+Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name physicalPath -Value $publicDir
+Set-ItemProperty ('IIS:\Sites\' + $SiteName) -Name applicationPool -Value $AppPool
+
+if($RunMigrations){
+ Write-Host 'Running pending migrations'
+ Push-Location $RemoteDir
+ try {
+ & C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up
+ if($LASTEXITCODE -ne 0){ throw 'runMigrations.vbs up failed - see output above' }
+ } finally {
+ Pop-Location
+ }
+}
+
+Write-Host "Starting IIS site $SiteName and app pool $AppPool"
+if((Get-WebAppPoolState -Name $AppPool).Value -eq 'Started'){
+ Restart-WebAppPool -Name $AppPool
+} else {
+ Start-WebAppPool -Name $AppPool
+}
+Start-Website $SiteName
+
+Write-Host 'Remote apply complete.'
diff --git a/scripts/deploy-iis.ps1 b/scripts/deploy-iis.ps1
new file mode 100644
index 0000000..e654f26
--- /dev/null
+++ b/scripts/deploy-iis.ps1
@@ -0,0 +1,115 @@
+<#
+ Deploys the Purple Envelope Orders Site to the production IIS server.
+
+ Flow:
+ 1. Build a release locally (scripts\build-release.ps1) - a clean file tree with the
+ production web.config swapped in, and dev-only files excluded via .gitattributes.
+ 2. Zip it and scp the zip to the remote host.
+ 3. scp scripts\deploy-iis-remote-apply.ps1 to the remote host and run it over ssh - it
+ wipes the deploy directory, extracts the new release, points IIS at it, runs
+ migrations, and restarts the site/app pool. The database and error log live outside
+ the deploy directory, so the wipe never touches them.
+ 4. Smoke-test a few routes over HTTPS from this machine.
+
+ Usage:
+ powershell -File scripts\deploy-iis.ps1
+ powershell -File scripts\deploy-iis.ps1 -Ref master
+
+ Remote target defaults to the "ssh user@host" line in scripts\deployinfo.txt (gitignored,
+ not committed - create it locally, e.g.: ssh daniel_admin@kci-uluto-web).
+#>
+
+param(
+ [string]$Ref = 'HEAD',
+ [string]$RemoteTarget = '',
+ [int]$RemotePort = 22,
+ [string]$RemoteDir = 'C:\inetpub\wwwroot\Purple_Envelop_Order_Site',
+ [string]$SiteName = 'PurpleEnvelopes',
+ [string]$AppPool = 'PurpleEnvelopes',
+ [string]$DbPath = 'C:\inetpub\data\webdata.accdb',
+ [string]$BaseUrl = 'https://pe.kentcommunications.com/',
+ [switch]$RunMigrations = $true,
+ [string]$SshExe = 'ssh',
+ [string]$ScpExe = 'scp'
+)
+
+$ErrorActionPreference = 'Stop'
+$repoRoot = Split-Path $PSScriptRoot -Parent
+
+function Ensure-Command {
+ param([string]$Name)
+ if(!(Get-Command $Name -ErrorAction SilentlyContinue)){
+ throw "$Name not found on PATH"
+ }
+}
+
+function Get-DefaultRemoteTarget {
+ $infoPath = Join-Path $PSScriptRoot 'deployinfo.txt'
+ if(!(Test-Path $infoPath)){ return '' }
+ $sshLine = Get-Content $infoPath | Where-Object { $_ -match '^\s*ssh\s+' } | Select-Object -First 1
+ if([string]::IsNullOrWhiteSpace($sshLine)){ return '' }
+ return ($sshLine -replace '^\s*ssh\s+', '').Trim()
+}
+
+if([string]::IsNullOrWhiteSpace($RemoteTarget)){
+ $RemoteTarget = Get-DefaultRemoteTarget
+}
+if([string]::IsNullOrWhiteSpace($RemoteTarget)){
+ throw 'No -RemoteTarget given and scripts\deployinfo.txt not found. Create scripts\deployinfo.txt with a line like: ssh user@host'
+}
+
+Ensure-Command $SshExe
+Ensure-Command $ScpExe
+Ensure-Command git
+
+# --- 1. Build ---
+$outDir = Join-Path $repoRoot ('dist\release_' + (Get-Date -Format 'yyyyMMdd_HHmmss'))
+& (Join-Path $PSScriptRoot 'build-release.ps1') -Ref $Ref -OutDir $outDir
+if($LASTEXITCODE -ne 0 -and $LASTEXITCODE -ne $null){ throw 'build-release.ps1 failed' }
+
+# --- 2. Zip and ship it ---
+$zipName = 'release_' + (Get-Date -Format 'yyyyMMdd_HHmmss') + '.zip'
+$localZip = Join-Path (Split-Path $outDir -Parent) $zipName
+Compress-Archive -Path (Join-Path $outDir '*') -DestinationPath $localZip -Force
+
+$remoteZip = 'C:\Windows\Temp\' + $zipName
+$remoteApplyScript = Join-Path $PSScriptRoot 'deploy-iis-remote-apply.ps1'
+$remoteApplyDest = 'C:\Windows\Temp\deploy-iis-remote-apply.ps1'
+
+Write-Host "Copying release to $RemoteTarget"
+& $ScpExe -P $RemotePort $localZip "${RemoteTarget}:$remoteZip"
+if($LASTEXITCODE -ne 0){ throw 'scp of release zip failed' }
+
+& $ScpExe -P $RemotePort $remoteApplyScript "${RemoteTarget}:$remoteApplyDest"
+if($LASTEXITCODE -ne 0){ throw 'scp of remote-apply script failed' }
+
+# --- 3. Apply on the remote host ---
+$remoteCommandParts = @(
+ 'powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass',
+ '-File', ('"' + $remoteApplyDest + '"'),
+ '-ZipPath', ('"' + $remoteZip + '"'),
+ '-RemoteDir', ('"' + $RemoteDir + '"'),
+ '-SiteName', ('"' + $SiteName + '"'),
+ '-AppPool', ('"' + $AppPool + '"'),
+ '-DbPath', ('"' + $DbPath + '"')
+)
+if($RunMigrations){ $remoteCommandParts += '-RunMigrations' }
+
+Write-Host "Applying release on $RemoteTarget"
+& $SshExe -p $RemotePort $RemoteTarget ($remoteCommandParts -join ' ')
+if($LASTEXITCODE -ne 0){ throw 'remote apply failed' }
+
+# --- 4. Local cleanup ---
+Remove-Item $localZip -ErrorAction SilentlyContinue
+Remove-Item -Recurse -Force $outDir -ErrorAction SilentlyContinue
+
+# --- 5. Smoke test ---
+Write-Host 'Smoke testing...'
+$paths = @('/', '/request-order', '/404')
+foreach($path in $paths){
+ $url = $BaseUrl.TrimEnd('/') + $path
+ $response = Invoke-WebRequest -UseBasicParsing -Uri $url -TimeoutSec 30
+ Write-Host ("OK " + $path + ' -> ' + $response.StatusCode)
+}
+
+Write-Host 'Deploy complete.'
diff --git a/scripts/migrate_isbusiness_to_households.vbs b/scripts/migrate_isbusiness_to_households.vbs
deleted file mode 100644
index 5f0a8b9..0000000
--- a/scripts/migrate_isbusiness_to_households.vbs
+++ /dev/null
@@ -1,90 +0,0 @@
-' migrate_isbusiness_to_households.vbs
-' Moves IsBusiness from HouseholderNames to Households.
-'
-' Usage:
-' cscript //nologo scripts\migrate_isbusiness_to_households.vbs "C:\path\to\myAccessFile.accdb"
-'
-' What it does:
-' 1) Adds Households.IsBusiness (SMALLINT) if missing
-' 2) Copies data: sets Households.IsBusiness=1 if any related HouseholderNames.IsBusiness<>0
-' 3) Sets NULLs to 0
-' 4) Drops HouseholderNames.IsBusiness if present
-'
-Option Explicit
-
-Dim dbPath
-If WScript.Arguments.Count < 1 Then
- WScript.Echo "ERROR: missing db path."
- WScript.Echo "Usage: cscript //nologo scripts\migrate_isbusiness_to_households.vbs ""C:\path\to\db.accdb"""
- WScript.Quit 1
-End If
-
-dbPath = WScript.Arguments(0)
-
-Dim conn
-Set conn = CreateObject("ADODB.Connection")
-conn.Open "Provider=Microsoft.ACE.OLEDB.12.0;Data Source=" & dbPath & ";Persist Security Info=False;"
-
-On Error Resume Next
-
-If Not ColumnExists(conn, "Households", "IsBusiness") Then
- Exec conn, "ALTER TABLE [Households] ADD COLUMN [IsBusiness] SMALLINT"
- If Err.Number <> 0 Then
- WScript.Echo "ERROR adding Households.IsBusiness: " & Err.Description
- WScript.Quit 1
- End If
- WScript.Echo "Added Households.IsBusiness"
-Else
- WScript.Echo "Households.IsBusiness already exists"
-End If
-
-' Copy data (only if the old column exists)
-If ColumnExists(conn, "HouseholderNames", "IsBusiness") Then
- ' Normalize all existing households first so the column is never left NULL.
- Exec conn, "UPDATE [Households] SET [IsBusiness]=0"
- If Err.Number <> 0 Then
- WScript.Echo "ERROR initializing Households.IsBusiness: " & Err.Description
- WScript.Quit 1
- End If
-
- ' Promote households to business when any related name was previously marked as a business.
- Exec conn, "UPDATE [Households] SET [IsBusiness]=1 WHERE [Id] IN (SELECT [HouseholdId] FROM [HouseholderNames] WHERE [IsBusiness]<>0)"
- If Err.Number <> 0 Then
- WScript.Echo "ERROR copying IsBusiness to Households: " & Err.Description
- WScript.Quit 1
- End If
- WScript.Echo "Copied IsBusiness values to Households"
-
- Exec conn, "ALTER TABLE [HouseholderNames] DROP COLUMN [IsBusiness]"
- If Err.Number <> 0 Then
- WScript.Echo "ERROR dropping HouseholderNames.IsBusiness: " & Err.Description
- WScript.Quit 1
- End If
- WScript.Echo "Dropped HouseholderNames.IsBusiness"
-Else
- WScript.Echo "HouseholderNames.IsBusiness does not exist; nothing to drop"
-End If
-
-conn.Close
-Set conn = Nothing
-WScript.Echo "Done."
-
-' --- helpers ---
-Sub Exec(c, sql)
- Err.Clear
- c.Execute sql
-End Sub
-
-Function ColumnExists(c, tableName, colName)
- Dim rs
- ColumnExists = False
- Err.Clear
- Set rs = c.OpenSchema(4, Array(Empty, Empty, tableName, colName)) ' adSchemaColumns=4
- If Err.Number <> 0 Then
- Err.Clear
- Exit Function
- End If
- If Not rs.EOF Then ColumnExists = True
- rs.Close
- Set rs = Nothing
-End Function